From 2db9ed1e71a3b1bad05997a13ace0c9fcfaf4237 Mon Sep 17 00:00:00 2001 From: TowyTowy <85077986+TowyTowy@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:49:35 +0200 Subject: [PATCH 01/15] Fix ZeroDivisionError for inverse unit conversions in recorder statistics (#176320) Co-authored-by: Claude Fable 5 Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com> --- .../components/recorder/statistics.py | 17 ++-- homeassistant/components/sensor/recorder.py | 12 ++- tests/components/recorder/test_statistics.py | 56 +++++++++++++ tests/components/sensor/test_recorder.py | 83 +++++++++++++++++++ 4 files changed, 155 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7a7225cfafe94f..284beb18568684 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -382,8 +382,7 @@ def _get_statistic_to_display_unit_converter( statistic_unit: str | None, state_unit: str | None, requested_units: dict[str, str] | None, - allow_none: bool = True, -) -> Callable[[float | None], float | None] | Callable[[float], float] | None: +) -> Callable[[float | None], float | None] | None: """Prepare a converter from the statistics unit to display unit.""" if (converter := _get_unit_converter(unit_class, statistic_unit)) is None: return None @@ -402,11 +401,9 @@ def _get_statistic_to_display_unit_converter( if display_unit == statistic_unit: return None - if allow_none: - return converter.converter_factory_allow_none( - from_unit=statistic_unit, to_unit=display_unit - ) - return converter.converter_factory(from_unit=statistic_unit, to_unit=display_unit) + return converter.converter_factory_allow_none( + from_unit=statistic_unit, to_unit=display_unit + ) def _get_display_to_statistic_unit_converter_func( @@ -2566,7 +2563,7 @@ def _build_sum_converted_stats( table_duration_seconds: float, start_ts_idx: int, sum_idx: int, - convert: Callable[[float | None], float | None] | Callable[[float], float], + convert: Callable[[float | None], float | None], ) -> list[StatisticsRow]: """Build a list of sum statistics.""" return [ @@ -2618,7 +2615,7 @@ def _build_converted_stats( table_duration_seconds: float, start_ts_idx: int, row_mapping: tuple[tuple[str, int], ...], - convert: Callable[[float | None], float | None] | Callable[[float], float], + convert: Callable[[float | None], float | None], ) -> list[StatisticsRow]: """Build a list of statistics with unit conversion.""" return [ @@ -2694,7 +2691,7 @@ def _sorted_statistics_to_dict( EntityStateAttribute.UNIT_OF_MEASUREMENT ) convert = _get_statistic_to_display_unit_converter( - unit_class, unit, state_unit, units, allow_none=False + unit_class, unit, state_unit, units ) else: convert = None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 79181b65359b1e..e80b96391bbf0f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -358,7 +358,7 @@ def _normalize_states( return unit_class, state_unit, fstates valid_fstates: list[tuple[float, State]] = [] - convert: Callable[[float], float] | None = None + convert: Callable[[float | None], float | None] | None = None last_unit: str | UndefinedType | None = UNDEFINED valid_units = converter.VALID_UNITS @@ -391,11 +391,17 @@ def _normalize_states( if state_unit == statistics_unit: convert = None else: - convert = converter.converter_factory(state_unit, statistics_unit) + convert = converter.converter_factory_allow_none( + state_unit, statistics_unit + ) last_unit = state_unit if convert is not None: - fstate = convert(fstate) + if (converted_fstate := convert(fstate)) is None: + # Exclude states which can't be converted, e.g. converting 0 + # between kWh/100km and km/kWh would divide by zero + continue + fstate = converted_fstate valid_fstates.append((fstate, state)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 12eec944ba51bc..0c8a60e2a90b60 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1522,6 +1522,62 @@ async def test_update_statistics_metadata_error( } +@pytest.mark.parametrize( + ("state", "converted_value"), + [ + pytest.param(0, None, id="zero"), + pytest.param(20, 5.0, id="non-zero"), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_statistics_during_period_display_inverse_unit( + hass: HomeAssistant, + state: int, + converted_value: float | None, +) -> None: + """Test fetching statistics with a display unit which is an inverse unit. + + A zero value has no representation in the inverse unit and should be + converted to None instead of raising ZeroDivisionError. + """ + now = get_start_time(dt_util.utcnow()) + + attributes = { + "device_class": "energy_distance", + "state_class": "measurement", + "unit_of_measurement": "kWh/100km", + } + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set( + "sensor.test", state, attributes=attributes, timestamp=now.timestamp() + ) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=now) + await async_wait_recording_done(hass) + + assert statistics_during_period( + hass, + now, + period="5minute", + statistic_ids={"sensor.test"}, + units={"energy_distance": "km/kWh"}, + ) == { + "sensor.test": [ + { + "end": (now + timedelta(minutes=5)).timestamp(), + "last_reset": None, + "max": converted_value, + "mean": converted_value, + "min": converted_value, + "start": now.timestamp(), + } + ], + } + + @pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b92411ee1225f5..a4c40e7ee7a04c 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -3843,6 +3843,89 @@ async def test_compile_hourly_statistics_convert_units_1( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_convert_zero_to_inverse_unit( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling statistics when a sensor changes to an inverse unit. + + A zero value has no representation in the inverse unit used for the + previously compiled statistics and should be skipped instead of raising + ZeroDivisionError. + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, DOMAIN, {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + attributes = { + "device_class": "energy_distance", + "state_class": "measurement", + "unit_of_measurement": "kWh/100km", + } + with freeze_time(zero) as freezer: + await async_record_states( + hass, freezer, zero, "sensor.test1", attributes, seq=[16, 16, None] + ) + attributes["unit_of_measurement"] = "km/kWh" + await async_record_states( + hass, + freezer, + zero + timedelta(minutes=5), + "sensor.test1", + attributes, + seq=[0, 20, 20], + ) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=zero) + do_adhoc_statistics(hass, start=zero + timedelta(minutes=5)) + await async_wait_recording_done(hass) + + assert "Error while processing event StatisticsTask" not in caplog.text + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": "km/kWh", + "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": "kWh/100km", + "unit_class": "energy_distance", + }, + ] + # The zero state at the start of the second period is skipped as it has no + # representation in kWh/100km, the 20 km/kWh states convert to 5 kWh/100km. + # The stored statistics are displayed converted to the current state unit + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(100 / 16), + "min": pytest.approx(100 / 16), + "max": pytest.approx(100 / 16), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "start": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=10)).timestamp(), + "mean": pytest.approx(20.0), + "min": pytest.approx(20.0), + "max": pytest.approx(20.0), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + + @pytest.mark.parametrize( ( "device_class", From 3c46bcb85a7ca70cd0d962362a0c38d531dd0c09 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:48:20 +0200 Subject: [PATCH 02/15] Fix host override ignored in UniFi Protect public-only mode (#181176) --- .../components/unifiprotect/utils.py | 1 + tests/components/unifiprotect/test_utils.py | 59 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 9ac22de9dd1587..e5aef720a65b02 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -134,6 +134,7 @@ def async_create_api_client( public_api_session=async_create_clientsession(hass), devices_ws_subscribed_models=DEVICES_WS_SUBSCRIBED_MODELS, ignore_unadopted=False, + override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ) return _async_create_full_client(hass, entry) diff --git a/tests/components/unifiprotect/test_utils.py b/tests/components/unifiprotect/test_utils.py index 0c1c1a3b2b4341..c6f438b284413f 100644 --- a/tests/components/unifiprotect/test_utils.py +++ b/tests/components/unifiprotect/test_utils.py @@ -1,15 +1,31 @@ """Test the UniFi Protect utils.""" +import pytest + from homeassistant.components.unifiprotect.const import ( CONF_CONNECTION_MODE, + CONF_OVERRIDE_CHOST, CONNECTION_MODE_API_KEY_ONLY, + DOMAIN, +) +from homeassistant.components.unifiprotect.utils import ( + async_create_api_client, + async_create_session_client, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, ) -from homeassistant.components.unifiprotect.utils import async_create_session_client -from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from .utils import MockUFPFixture +from tests.common import MockConfigEntry + async def test_session_client_is_full_access_for_api_key_only_entry( hass: HomeAssistant, ufp: MockUFPFixture @@ -41,3 +57,42 @@ async def test_session_client_none_without_credentials( ) assert async_create_session_client(hass, ufp.entry) is None + + +@pytest.mark.parametrize( + ("connection_mode", "public_only"), + [ + pytest.param({}, False, id="hybrid"), + pytest.param( + {CONF_CONNECTION_MODE: CONNECTION_MODE_API_KEY_ONLY}, True, id="public_only" + ), + ], +) +async def test_host_override_reaches_the_client( + hass: HomeAssistant, connection_mode: dict[str, str], public_only: bool +) -> None: + """The host override option has to reach the client in both modes. + + The library rewrites the host of the public RTSPS URLs only when the + client carries the flag, and a public-only entry has no other stream + source to fall back on. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 443, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_API_KEY: "test-api-key", + CONF_VERIFY_SSL: False, + **connection_mode, + }, + options={CONF_OVERRIDE_CHOST: True}, + ) + entry.add_to_hass(hass) + + protect = async_create_api_client(hass, entry) + + assert protect.is_public_only is public_only + assert protect.override_connection_host is True From 06c07ad41b73881688104062a94f8b885c7c822d Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:52:12 +0200 Subject: [PATCH 03/15] Add Sofar service actions for the paired-register controls (#180892) --- homeassistant/components/sofar/__init__.py | 11 + homeassistant/components/sofar/icons.json | 14 + .../components/sofar/quality_scale.yaml | 12 +- homeassistant/components/sofar/services.py | 191 +++++++++++ homeassistant/components/sofar/services.yaml | 102 ++++++ homeassistant/components/sofar/strings.json | 105 ++++++ tests/components/sofar/test_services.py | 301 ++++++++++++++++++ 7 files changed, 727 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/sofar/services.py create mode 100644 homeassistant/components/sofar/services.yaml create mode 100644 tests/components/sofar/test_services.py diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index dd455c2a3456d5..1af5d8b5ffcae2 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -16,14 +16,17 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import ( + config_validation as cv, device_registry as dr, entity_registry as er, restore_state, ) +from homeassistant.helpers.typing import ConfigType from .const import CONF_UNIT_ID, DOMAIN, SCAN_INTERVAL, SETTINGS_SCAN_INTERVAL from .coordinator import SofarConfigEntry, SofarDataUpdateCoordinator, SofarRuntimeData from .sensor import SENSOR_DESCRIPTIONS +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -37,6 +40,8 @@ _IDENTITY_ATTEMPTS = 3 +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + def _async_remove_stale_waiting_time(hass: HomeAssistant, serial: str) -> None: """Drop the removed waiting-time entity so it doesn't linger unavailable.""" @@ -84,6 +89,12 @@ def _async_seed_high_water_marks( ) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Sofar integration.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: SofarConfigEntry) -> bool: """Set up Sofar Inverter Modbus from a config entry.""" serial = entry.unique_id diff --git a/homeassistant/components/sofar/icons.json b/homeassistant/components/sofar/icons.json index 65fbca1c7fea8a..34798ee1ed3863 100644 --- a/homeassistant/components/sofar/icons.json +++ b/homeassistant/components/sofar/icons.json @@ -31,5 +31,19 @@ "default": "mdi:battery-heart" } } + }, + "services": { + "set_active_power_limit": { + "service": "mdi:speedometer-slow" + }, + "set_feed_in_limit": { + "service": "mdi:transmission-tower-export" + }, + "set_passive_mode_power": { + "service": "mdi:home-battery" + }, + "set_passive_mode_timeout": { + "service": "mdi:timer-cog-outline" + } } } diff --git a/homeassistant/components/sofar/quality_scale.yaml b/homeassistant/components/sofar/quality_scale.yaml index 12e6a96b089c61..43cf2d912eafc0 100644 --- a/homeassistant/components/sofar/quality_scale.yaml +++ b/homeassistant/components/sofar/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: This integration does not register any service actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: This integration does not register any service actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not provide any conditions. @@ -32,9 +28,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: This integration does not register any service actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/sofar/services.py b/homeassistant/components/sofar/services.py new file mode 100644 index 00000000000000..06282eac6834b0 --- /dev/null +++ b/homeassistant/components/sofar/services.py @@ -0,0 +1,191 @@ +"""Services for the Sofar integration.""" + +from collections.abc import Awaitable + +from modbus_connection import ModbusError +from sofar_modbus.modern.enums import FeedinLimitationMode, PassiveModeTimeoutAction +import voluptuous as vol + +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import ( + async_get_config_entry, + async_register_admin_service, +) + +from .const import DOMAIN +from .coordinator import SofarConfigEntry + +SERVICE_SET_ACTIVE_POWER_LIMIT = "set_active_power_limit" +SERVICE_SET_FEED_IN_LIMIT = "set_feed_in_limit" +SERVICE_SET_PASSIVE_MODE_POWER = "set_passive_mode_power" +SERVICE_SET_PASSIVE_MODE_TIMEOUT = "set_passive_mode_timeout" + +ATTR_ACTION = "action" +ATTR_BATTERY_POWER_MAX = "battery_power_max" +ATTR_BATTERY_POWER_MIN = "battery_power_min" +ATTR_ENABLED = "enabled" +ATTR_GRID_POWER = "grid_power" +ATTR_LIMIT = "limit" +ATTR_MAX_POWER = "max_power" +ATTR_TIMEOUT = "timeout" + +_ENTRY_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): str}) + +# The selectors in services.yaml only bound the UI, not a scripted call. +_POWER_RANGE = vol.All(int, vol.Range(min=-100000, max=100000)) + +SET_FEED_IN_LIMIT_SCHEMA = _ENTRY_SCHEMA.extend( + { + vol.Required(ATTR_MODE): vol.In( + [mode.name.lower() for mode in FeedinLimitationMode] + ), + # Kept fractional so the multiple-of-100 check below sees the real + # value; cv.positive_int would truncate 3000.9 into a valid 3000. + vol.Required(ATTR_MAX_POWER): vol.All( + vol.Coerce(float), vol.Range(min=0, max=100000) + ), + } +) + +SET_ACTIVE_POWER_LIMIT_SCHEMA = _ENTRY_SCHEMA.extend( + { + vol.Required(ATTR_ENABLED): cv.boolean, + vol.Required(ATTR_LIMIT): vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), + } +) + +SET_PASSIVE_MODE_TIMEOUT_SCHEMA = _ENTRY_SCHEMA.extend( + { + vol.Required(ATTR_TIMEOUT): vol.All(cv.positive_int, vol.Range(max=65535)), + vol.Required(ATTR_ACTION): vol.In( + [action.name.lower() for action in PassiveModeTimeoutAction] + ), + } +) + +SET_PASSIVE_MODE_POWER_SCHEMA = _ENTRY_SCHEMA.extend( + { + vol.Required(ATTR_GRID_POWER): _POWER_RANGE, + vol.Required(ATTR_BATTERY_POWER_MIN): _POWER_RANGE, + vol.Required(ATTR_BATTERY_POWER_MAX): _POWER_RANGE, + } +) + + +def _get_entry( + hass: HomeAssistant, call: ServiceCall, component: str +) -> SofarConfigEntry: + """Return a loaded entry whose inverter serves the needed registers.""" + entry: SofarConfigEntry = async_get_config_entry( + hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + if component not in entry.runtime_data.served_components: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_action", + translation_placeholders={"title": entry.title}, + ) + return entry + + +async def _write(entry: SofarConfigEntry, write: Awaitable[None]) -> None: + """Translate a failed write, then let the settings sensors catch up.""" + try: + await write + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_action_value", + ) from err + except ModbusError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_failed", + ) from err + await entry.runtime_data.settings.async_request_refresh() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Sofar services.""" + + async def _handle_set_feed_in_limit(call: ServiceCall) -> None: + entry = _get_entry(hass, call, "feed_in") + device = entry.runtime_data.readings.device + max_power = call.data[ATTR_MAX_POWER] + if max_power % 100: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="max_power_not_a_multiple_of_100", + ) + await _write( + entry, + device.feed_in.async_write_limit( + FeedinLimitationMode[call.data[ATTR_MODE].upper()], int(max_power) + ), + ) + + async def _handle_set_active_power_limit(call: ServiceCall) -> None: + entry = _get_entry(hass, call, "active_power_control") + device = entry.runtime_data.readings.device + await _write( + entry, + device.active_power_control.async_write_active_power_limit( + call.data[ATTR_ENABLED], call.data[ATTR_LIMIT] + ), + ) + + async def _handle_set_passive_mode_timeout(call: ServiceCall) -> None: + entry = _get_entry(hass, call, "passive") + device = entry.runtime_data.readings.device + await _write( + entry, + device.passive.async_write_timeout( + call.data[ATTR_TIMEOUT], + PassiveModeTimeoutAction[call.data[ATTR_ACTION].upper()], + ), + ) + + async def _handle_set_passive_mode_power(call: ServiceCall) -> None: + entry = _get_entry(hass, call, "passive") + device = entry.runtime_data.readings.device + await _write( + entry, + device.passive.async_write_power( + call.data[ATTR_GRID_POWER], + call.data[ATTR_BATTERY_POWER_MIN], + call.data[ATTR_BATTERY_POWER_MAX], + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_FEED_IN_LIMIT, + _handle_set_feed_in_limit, + schema=SET_FEED_IN_LIMIT_SCHEMA, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ACTIVE_POWER_LIMIT, + _handle_set_active_power_limit, + schema=SET_ACTIVE_POWER_LIMIT_SCHEMA, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_PASSIVE_MODE_TIMEOUT, + _handle_set_passive_mode_timeout, + schema=SET_PASSIVE_MODE_TIMEOUT_SCHEMA, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_PASSIVE_MODE_POWER, + _handle_set_passive_mode_power, + schema=SET_PASSIVE_MODE_POWER_SCHEMA, + ) diff --git a/homeassistant/components/sofar/services.yaml b/homeassistant/components/sofar/services.yaml new file mode 100644 index 00000000000000..9c292dc685bed6 --- /dev/null +++ b/homeassistant/components/sofar/services.yaml @@ -0,0 +1,102 @@ +set_feed_in_limit: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: sofar + mode: + required: true + selector: + select: + translation_key: feedin_limitation_mode + options: + - disabled + - enabled_feed_in_limitation + - enabled_3_phase_limit + max_power: + required: true + selector: + number: + min: 0 + max: 100000 + step: 100 + unit_of_measurement: W + mode: box + +set_active_power_limit: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: sofar + enabled: + required: true + selector: + boolean: + limit: + required: true + selector: + number: + min: 0 + max: 100 + step: 0.1 + unit_of_measurement: "%" + mode: box + +set_passive_mode_timeout: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: sofar + timeout: + required: true + selector: + number: + min: 0 + max: 65535 + unit_of_measurement: s + mode: box + action: + required: true + selector: + select: + translation_key: passive_mode_timeout_action + options: + - force_standby + - return_to_previous_mode + +set_passive_mode_power: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: sofar + grid_power: + required: true + selector: + number: + min: -100000 + max: 100000 + unit_of_measurement: W + mode: box + battery_power_min: + required: true + selector: + number: + min: -100000 + max: 100000 + unit_of_measurement: W + mode: box + battery_power_max: + required: true + selector: + number: + min: -100000 + max: 100000 + unit_of_measurement: W + mode: box diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 5e9c01bda5216b..632fca51562725 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -569,6 +569,12 @@ } }, "exceptions": { + "invalid_action_value": { + "message": "The provided value is not valid for this action." + }, + "max_power_not_a_multiple_of_100": { + "message": "Maximum power must be a multiple of 100 W." + }, "modbus_error": { "message": "{error}" }, @@ -577,6 +583,105 @@ }, "unrecognized_inverter_model": { "message": "Unrecognized Sofar inverter model for {title}." + }, + "unsupported_action": { + "message": "{title} does not support this action." + }, + "write_failed": { + "message": "Failed to write to the inverter." + } + }, + "selector": { + "feedin_limitation_mode": { + "options": { + "disabled": "Disabled", + "enabled_3_phase_limit": "Enabled, three-phase limit", + "enabled_feed_in_limitation": "Enabled" + } + }, + "passive_mode_timeout_action": { + "options": { + "force_standby": "Force standby", + "return_to_previous_mode": "Return to previous mode" + } + } + }, + "services": { + "set_active_power_limit": { + "description": "Caps the inverter's own output as a percentage of its rated power, which is the power rating on the inverter's nameplate, usually also in its model name.", + "fields": { + "config_entry_id": { + "description": "The Sofar inverter to send this to.", + "name": "Inverter" + }, + "enabled": { + "description": "Whether the inverter applies the limit. Limit is required either way: disabling still writes it, but the device ignores it until re-enabled.", + "name": "Enabled" + }, + "limit": { + "description": "The output ceiling, as a percentage of rated power. On a 4.4 kW inverter, 50% caps it at 2.2 kW.", + "name": "Limit" + } + }, + "name": "Set active power limit" + }, + "set_feed_in_limit": { + "description": "Limits how much power the inverter exports to the grid.", + "fields": { + "config_entry_id": { + "description": "The Sofar inverter to send this to.", + "name": "Inverter" + }, + "max_power": { + "description": "The export ceiling in watts. The inverter only accepts multiples of 100 W.", + "name": "Maximum power" + }, + "mode": { + "description": "Whether to limit the total exported power, or per phase.", + "name": "Mode" + } + }, + "name": "Set feed-in limit" + }, + "set_passive_mode_power": { + "description": "Commands the passive-mode setpoints. Only for inverters with battery storage.", + "fields": { + "battery_power_max": { + "description": "The upper end of the battery power window.", + "name": "Maximum battery power" + }, + "battery_power_min": { + "description": "The lower end of the battery power window.", + "name": "Minimum battery power" + }, + "config_entry_id": { + "description": "The Sofar inverter to send this to.", + "name": "Inverter" + }, + "grid_power": { + "description": "The power to draw from the grid, or to export when negative.", + "name": "Grid power" + } + }, + "name": "Set passive mode power" + }, + "set_passive_mode_timeout": { + "description": "Sets how long a passive-mode command holds, and what the inverter does when it expires. Only for inverters with battery storage.", + "fields": { + "action": { + "description": "What the inverter does once the timeout expires.", + "name": "Timeout action" + }, + "config_entry_id": { + "description": "The Sofar inverter to send this to.", + "name": "Inverter" + }, + "timeout": { + "description": "How long a passive-mode command holds, in seconds.", + "name": "Timeout" + } + }, + "name": "Set passive mode timeout" } } } diff --git a/tests/components/sofar/test_services.py b/tests/components/sofar/test_services.py new file mode 100644 index 00000000000000..8ce3c719ef1995 --- /dev/null +++ b/tests/components/sofar/test_services.py @@ -0,0 +1,301 @@ +"""Test the Sofar Inverter Modbus services.""" + +from unittest.mock import patch + +from modbus_connection import ModbusError +from modbus_connection.mock import MockModbusConnection +import pytest +import voluptuous as vol + +from homeassistant.components.sofar.const import DOMAIN +from homeassistant.components.sofar.services import ( + ATTR_ACTION, + ATTR_BATTERY_POWER_MAX, + ATTR_BATTERY_POWER_MIN, + ATTR_ENABLED, + ATTR_GRID_POWER, + ATTR_LIMIT, + ATTR_MAX_POWER, + ATTR_TIMEOUT, + SERVICE_SET_ACTIVE_POWER_LIMIT, + SERVICE_SET_FEED_IN_LIMIT, + SERVICE_SET_PASSIVE_MODE_POWER, + SERVICE_SET_PASSIVE_MODE_TIMEOUT, +) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import ( + MOCK_HYBRID_MODEL, + MOCK_HYBRID_SERIAL, + MOCK_USER_INPUT, + seed_hybrid_inverter, +) + +from tests.common import MockConfigEntry + +FEED_IN_MODE_REGISTER = 0x1023 +FEED_IN_POWER_REGISTER = 0x1024 +POWER_CONTROL_REGISTER = 0x1105 +ACTIVE_POWER_LIMIT_REGISTER = 0x1106 +PASSIVE_TIMEOUT_REGISTER = 0x1184 +PASSIVE_TIMEOUT_ACTION_REGISTER = 0x1185 +PASSIVE_GRID_POWER_REGISTER = 0x1187 +PASSIVE_BATTERY_POWER_MIN_REGISTER = 0x1189 +PASSIVE_BATTERY_POWER_MAX_REGISTER = 0x118B + + +async def _setup_hybrid( + hass: HomeAssistant, +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a hybrid inverter, which serves the passive-mode registers.""" + connection = MockModbusConnection() + seed_hybrid_inverter(connection.for_unit(1)) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_HYBRID_SERIAL, + data=MOCK_USER_INPUT, + title=MOCK_HYBRID_MODEL, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: connection.for_unit(unit_id), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return entry, connection + + +async def test_set_feed_in_limit( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + init_integration: MockConfigEntry, +) -> None: + """Test the feed-in limit reaches both registers as one write.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FEED_IN_LIMIT, + { + ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, + ATTR_MODE: "enabled_feed_in_limitation", + ATTR_MAX_POWER: 3000, + }, + blocking=True, + ) + + holding = mock_connection.for_unit(1).holding + assert holding[FEED_IN_MODE_REGISTER] == 1 + # The register counts in 100 W steps. + assert holding[FEED_IN_POWER_REGISTER] == 30 + + +async def test_set_active_power_limit( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + init_integration: MockConfigEntry, +) -> None: + """Test the active power limit arms its flag and writes the percentage.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_ACTIVE_POWER_LIMIT, + { + ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, + ATTR_ENABLED: True, + ATTR_LIMIT: 42.5, + }, + blocking=True, + ) + + holding = mock_connection.for_unit(1).holding + assert holding[POWER_CONTROL_REGISTER] == 1 + # The register counts in 0.1% steps. + assert holding[ACTIVE_POWER_LIMIT_REGISTER] == 425 + + +async def test_set_passive_mode_timeout(hass: HomeAssistant) -> None: + """Test the passive-mode timeout and its action go out together.""" + entry, connection = await _setup_hybrid(hass) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PASSIVE_MODE_TIMEOUT, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_TIMEOUT: 300, + ATTR_ACTION: "return_to_previous_mode", + }, + blocking=True, + ) + + holding = connection.for_unit(1).holding + assert holding[PASSIVE_TIMEOUT_REGISTER] == 300 + assert holding[PASSIVE_TIMEOUT_ACTION_REGISTER] == 1 + + +async def test_set_passive_mode_power(hass: HomeAssistant) -> None: + """Test the three passive-mode setpoints go out as one block.""" + entry, connection = await _setup_hybrid(hass) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PASSIVE_MODE_POWER, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_GRID_POWER: 1000, + ATTR_BATTERY_POWER_MIN: -2000, + ATTR_BATTERY_POWER_MAX: 2000, + }, + blocking=True, + ) + + holding = connection.for_unit(1).holding + # Each setpoint is a signed 32-bit value over two registers. + assert holding[PASSIVE_GRID_POWER_REGISTER] == 0 + assert holding[PASSIVE_GRID_POWER_REGISTER + 1] == 1000 + assert holding[PASSIVE_BATTERY_POWER_MIN_REGISTER] == 0xFFFF + assert holding[PASSIVE_BATTERY_POWER_MIN_REGISTER + 1] == 63536 + assert holding[PASSIVE_BATTERY_POWER_MAX_REGISTER] == 0 + assert holding[PASSIVE_BATTERY_POWER_MAX_REGISTER + 1] == 2000 + + +@pytest.mark.parametrize( + ("service", "data"), + [ + ( + SERVICE_SET_PASSIVE_MODE_TIMEOUT, + {ATTR_TIMEOUT: 60, ATTR_ACTION: "force_standby"}, + ), + ( + SERVICE_SET_PASSIVE_MODE_POWER, + { + ATTR_GRID_POWER: 0, + ATTR_BATTERY_POWER_MIN: 0, + ATTR_BATTERY_POWER_MAX: 0, + }, + ), + ], +) +async def test_action_rejected_when_unsupported( + hass: HomeAssistant, + init_integration: MockConfigEntry, + service: str, + data: dict[str, int | str], +) -> None: + """Test a passive-mode action is refused by a PV-only inverter.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, **data}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "max_power", + [pytest.param(3050, id="int"), pytest.param(3000.9, id="fractional")], +) +async def test_max_power_not_a_multiple_of_100_is_a_service_error( + hass: HomeAssistant, init_integration: MockConfigEntry, max_power: float +) -> None: + """Test a non-multiple-of-100 max_power is rejected before writing.""" + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FEED_IN_LIMIT, + { + ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, + ATTR_MODE: "disabled", + ATTR_MAX_POWER: max_power, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "max_power_not_a_multiple_of_100" + + +async def test_unexpected_value_error_is_a_service_error( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test a library ValueError the handlers don't preempt still translates.""" + device = init_integration.runtime_data.readings.device + with ( + patch.object( + device.feed_in, "async_write_limit", side_effect=ValueError("boom") + ), + pytest.raises(ServiceValidationError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FEED_IN_LIMIT, + { + ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, + ATTR_MODE: "disabled", + ATTR_MAX_POWER: 3000, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "invalid_action_value" + + +async def test_write_failure_is_a_home_assistant_error( + hass: HomeAssistant, + mock_connection: MockModbusConnection, + init_integration: MockConfigEntry, +) -> None: + """Test a ModbusError surfaces as a HomeAssistantError.""" + mock_connection.for_unit(1).fail_write(FEED_IN_MODE_REGISTER, ModbusError("busy")) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FEED_IN_LIMIT, + { + ATTR_CONFIG_ENTRY_ID: init_integration.entry_id, + ATTR_MODE: "disabled", + ATTR_MAX_POWER: 0, + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "data"), + [ + pytest.param( + SERVICE_SET_FEED_IN_LIMIT, + {ATTR_MODE: "disabled", ATTR_MAX_POWER: 10_000_000}, + id="feed_in_max_power", + ), + pytest.param( + SERVICE_SET_PASSIVE_MODE_TIMEOUT, + {ATTR_TIMEOUT: 70000, ATTR_ACTION: "force_standby"}, + id="passive_timeout", + ), + pytest.param( + SERVICE_SET_PASSIVE_MODE_POWER, + { + ATTR_GRID_POWER: 200_000, + ATTR_BATTERY_POWER_MIN: 0, + ATTR_BATTERY_POWER_MAX: 0, + }, + id="passive_grid_power", + ), + ], +) +async def test_value_past_the_selector_bounds_is_refused( + hass: HomeAssistant, + service: str, + data: dict[str, int | str], +) -> None: + """Test the schema bounds a scripted call, which skips the selectors.""" + entry, _ = await _setup_hybrid(hass) + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_CONFIG_ENTRY_ID: entry.entry_id, **data}, + blocking=True, + ) From f5fae769c41b98cf83b7992be4eea36150f3095c Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 3 Sep 2026 19:53:58 +0200 Subject: [PATCH 04/15] Warn that removing an app deletes its data in repair flows (#181009) --- homeassistant/components/hassio/const.py | 10 ++++++---- homeassistant/components/hassio/strings.json | 6 +++--- tests/components/hassio/test_repairs.py | 3 +++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index c2526117bde389..d05a16fa2a79e4 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -196,16 +196,18 @@ CONTAINER_STATS = "stats" REQUEST_REFRESH_DELAY = 10 -HELP_URLS = { +# Issues offering to uninstall an app, which deletes the app data as well +APP_REMOVE_URLS = { "help_url": "https://www.home-assistant.io/help/", "community_url": "https://community.home-assistant.io/", + "backup_url": "/config/backup", } EXTRA_PLACEHOLDERS = { "issue_mount_mount_failed": { "storage_url": "/config/storage", }, - ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: APP_REMOVE_URLS, ISSUE_KEY_SYSTEM_FREE_SPACE: { "more_info_free_space": "https://www.home-assistant.io/more-info/free-space", "storage_url": "/config/storage", @@ -213,8 +215,8 @@ ISSUE_KEY_ADDON_PWNED: { "more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords", }, - ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS, - ISSUE_KEY_ADDON_DEPRECATED_ARCH: HELP_URLS, + ISSUE_KEY_ADDON_DEPRECATED: APP_REMOVE_URLS, + ISSUE_KEY_ADDON_DEPRECATED_ARCH: APP_REMOVE_URLS, } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 603aa9411b2974..3b7f762c0638f3 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -105,7 +105,7 @@ }, "step": { "addon_execute_remove": { - "description": "App {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the app to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + "description": "App {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the app to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated app and permanently delete everything in its private data folder, including any databases, credentials and other internal state it kept there. Create a [backup]({backup_url}) first if you might need that data later.\n\nAlternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." } } }, @@ -118,7 +118,7 @@ }, "step": { "addon_execute_remove": { - "description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + "description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app and permanently delete everything in its private data folder, including any databases, credentials and other internal state it kept there. Create a [backup]({backup_url}) first if you might need that data later.\n\nAlternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." } } }, @@ -135,7 +135,7 @@ }, "step": { "addon_execute_remove": { - "description": "App {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + "description": "App {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall the app and permanently delete everything in its private data folder, including any databases, credentials and other internal state it kept there. Create a [backup]({backup_url}) first if you might need that data later.\n\nAlternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." } } }, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 3ff2880637ecee..30b44b57386702 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -1579,6 +1579,7 @@ async def test_supervisor_issue_detached_addon_removed( "addon": "test", "help_url": "https://www.home-assistant.io/help/", "community_url": "https://community.home-assistant.io/", + "backup_url": "/config/backup", }, "last_step": True, "preview": None, @@ -1778,6 +1779,7 @@ async def test_supervisor_issue_deprecated_addon( "addon": "test", "help_url": "https://www.home-assistant.io/help/", "community_url": "https://community.home-assistant.io/", + "backup_url": "/config/backup", "addon_info": "homeassistant://hassio/addon/test/info", "addon_documentation": "homeassistant://hassio/addon/test/documentation", }, @@ -1869,6 +1871,7 @@ async def test_supervisor_issue_deprecated_arch_addon( "addon": "test", "help_url": "https://www.home-assistant.io/help/", "community_url": "https://community.home-assistant.io/", + "backup_url": "/config/backup", }, "last_step": True, "preview": None, From 38df1695f212133864d57d9bc034e46dfa06a51f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Sep 2026 20:08:28 +0200 Subject: [PATCH 05/15] Purge unreachable deleted devices on device registry load (#181207) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/device_registry.py | 21 ++++++-- tests/helpers/test_device_registry.py | 64 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 3a4a76a72a071e..1f7f0f0699d1ba 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4156,6 +4156,7 @@ async def _async_load(self) -> None: child_devices = ChildDeviceRegistryItems() deleted_devices = DeletedDeviceRegistryItems() child_devices_dropped = False + empty_deleted_devices_dropped = 0 if data is not None: for device in data["devices"]: @@ -4259,6 +4260,14 @@ def get_optional_enum[_EnumT: StrEnum]( return None for device in data["deleted_devices"]: + # A deleted device with neither identifiers nor connections can never + # be restored (restore matches a re-registered device by identifier or + # connection) and serves no deduplication purpose, so it would linger + # forever. Current code cannot create one; drop such legacy cruft on + # load instead of carrying it in memory and rewriting it on every save. + if not device["identifiers"] and not device["connections"]: + empty_deleted_devices_dropped += 1 + continue deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], config_entry_id=device["config_entry_id"], @@ -4290,6 +4299,12 @@ def get_optional_enum[_EnumT: StrEnum]( shadowed_count, ) + if empty_deleted_devices_dropped: + _LOGGER.info( + "Dropped %d deleted devices with no identifiers or connections", + empty_deleted_devices_dropped, + ) + self._devices = devices self.devices = _DeprecatedDeviceRegistryItemsView(self._devices) self._child_devices = child_devices @@ -4298,9 +4313,9 @@ def get_optional_enum[_EnumT: StrEnum]( self._device_data = devices.data self._child_device_data = child_devices.data - # Persist dropped corrupt/orphaned children so the store isn't left dirty until - # an unrelated write - if child_devices_dropped: + # Persist dropped corrupt/orphaned children and empty deleted devices so the + # store isn't left dirty until an unrelated write + if child_devices_dropped or empty_deleted_devices_dropped: self.async_schedule_save() self._loaded_event.set() diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 1d0a93b027a0d2..2fe1eb7ebedb86 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -11700,6 +11700,70 @@ async def test_loading_child_device_with_missing_parent( assert hass_storage[dr.STORAGE_KEY]["data"]["child_devices"] == [] +@pytest.mark.parametrize("load_registries", [False]) +async def test_loading_drops_empty_deleted_devices( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test stored deleted devices with no identifiers or connections are dropped.""" + + def _deleted_device( + device_id: str, + identifiers: list[list[str]], + connections: list[list[str]], + ) -> dict[str, Any]: + return { + "area_id": None, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "connections": connections, + "created_at": "2024-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, + "id": device_id, + "identifiers": identifiers, + "labels": [], + "modified_at": "2024-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": None, + "domain": None, + } + + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [], + "child_devices": [], + "deleted_devices": [ + _deleted_device("with_identifiers", [["test", "1"]], []), + _deleted_device("with_connections", [], [["mac", "12:34:56:78:90:ab"]]), + _deleted_device("empty_1", [], []), + _deleted_device("empty_2", [], []), + ], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + assert set(registry._deleted_devices) == {"with_identifiers", "with_connections"} + assert "Dropped 2 deleted devices with no identifiers or connections" in caplog.text + + # The drop scheduled a save, so it persists instead of leaving the store dirty + # until an unrelated write + await flush_store(registry._store) + stored_ids = { + device["id"] + for device in hass_storage[dr.STORAGE_KEY]["data"]["deleted_devices"] + } + assert stored_ids == {"with_identifiers", "with_connections"} + + async def test_effective_area_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From f5809f5b873ba02e53d0ff549226e31585e566c5 Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:16:43 +0200 Subject: [PATCH 06/15] Add Sofar settings sensors for the paired-register controls (#181197) --- homeassistant/components/sofar/sensor.py | 72 +++ homeassistant/components/sofar/strings.json | 33 ++ .../sofar/snapshots/test_sensor.ambr | 448 ++++++++++++++++++ tests/components/sofar/test_sensor.py | 4 +- 4 files changed, 555 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sofar/sensor.py b/homeassistant/components/sofar/sensor.py index 85912f8b55d876..9ba1c5338db236 100644 --- a/homeassistant/components/sofar/sensor.py +++ b/homeassistant/components/sofar/sensor.py @@ -7,6 +7,7 @@ from typing import cast, override from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.modern.enums import FeedinLimitationMode, PassiveModeTimeoutAction from homeassistant.components.sensor import ( RestoreSensor, @@ -1440,6 +1441,77 @@ def _part_sensors( ], entity_category=EntityCategory.DIAGNOSTIC, ), + SofarSensorDescription( + key="feedin_limitation_mode", + component="feed_in", + translation_key="feedin_limitation_mode", + device_class=SensorDeviceClass.ENUM, + options=[mode.name.lower() for mode in FeedinLimitationMode], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="feedin_max_power", + component="feed_in", + translation_key="feedin_max_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="active_power_export_limit", + component="active_power_control", + translation_key="active_power_export_limit", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="passive_mode_timeout", + component="passive", + translation_key="passive_mode_timeout", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="passive_mode_timeout_action", + component="passive", + translation_key="passive_mode_timeout_action", + device_class=SensorDeviceClass.ENUM, + options=[action.name.lower() for action in PassiveModeTimeoutAction], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="passive_mode_grid_power", + component="passive", + translation_key="passive_mode_grid_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="passive_mode_battery_power_min", + component="passive", + translation_key="passive_mode_battery_power_min", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SofarSensorDescription( + key="passive_mode_battery_power_max", + component="passive", + translation_key="passive_mode_battery_power_max", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ) SENSOR_DESCRIPTIONS += _part_sensors( diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 632fca51562725..027f6413ec912a 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -131,6 +131,9 @@ } }, "sensor": { + "active_power_export_limit": { + "name": "Active power limit" + }, "active_power_load_sys": { "name": "Active power load system" }, @@ -317,6 +320,17 @@ "export_energy_total": { "name": "Export energy total" }, + "feedin_limitation_mode": { + "name": "Feed-in limitation mode", + "state": { + "disabled": "[%key:component::sofar::selector::feedin_limitation_mode::options::disabled%]", + "enabled_3_phase_limit": "[%key:component::sofar::selector::feedin_limitation_mode::options::enabled_3_phase_limit%]", + "enabled_feed_in_limitation": "[%key:component::sofar::selector::feedin_limitation_mode::options::enabled_feed_in_limitation%]" + } + }, + "feedin_max_power": { + "name": "Feed-in maximum power" + }, "grid_frequency": { "name": "Grid frequency" }, @@ -446,6 +460,25 @@ "passive_eps_wait_time": { "name": "EPS wait time" }, + "passive_mode_battery_power_max": { + "name": "Passive mode maximum battery power" + }, + "passive_mode_battery_power_min": { + "name": "Passive mode minimum battery power" + }, + "passive_mode_grid_power": { + "name": "Passive mode grid power" + }, + "passive_mode_timeout": { + "name": "Passive mode timeout" + }, + "passive_mode_timeout_action": { + "name": "Passive mode timeout action", + "state": { + "force_standby": "[%key:component::sofar::selector::passive_mode_timeout_action::options::force_standby%]", + "return_to_previous_mode": "[%key:component::sofar::selector::passive_mode_timeout_action::options::return_to_previous_mode%]" + } + }, "power": { "name": "Power" }, diff --git a/tests/components/sofar/snapshots/test_sensor.ambr b/tests/components/sofar/snapshots/test_sensor.ambr index 3cbc58f261061d..110591fe22d951 100644 --- a/tests/components/sofar/snapshots/test_sensor.ambr +++ b/tests/components/sofar/snapshots/test_sensor.ambr @@ -769,6 +769,57 @@ 'state': '51.5', }) # --- +# name: test_all_entities[sensor.hydxxktl_3p_active_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_active_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active power limit', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_export_limit', + 'unique_id': 'SP1XXES100XX_active_power_export_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_active_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P Active power limit', + : '%', + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_active_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.hydxxktl_3p_active_power_load_system-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3747,6 +3798,123 @@ 'state': '0.0', }) # --- +# name: test_all_entities[sensor.hydxxktl_3p_feed_in_limitation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disabled', + 'enabled_feed_in_limitation', + 'enabled_3_phase_limit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_feed_in_limitation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Feed-in limitation mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feed-in limitation mode', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feedin_limitation_mode', + 'unique_id': 'SP1XXES100XX_feedin_limitation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_feed_in_limitation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'HYDxxKTL-3P Feed-in limitation mode', + : list([ + 'disabled', + 'enabled_feed_in_limitation', + 'enabled_3_phase_limit', + ]), + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_feed_in_limitation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_feed_in_maximum_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_feed_in_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Feed-in maximum power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feed-in maximum power', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feedin_max_power', + 'unique_id': 'SP1XXES100XX_feedin_max_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_feed_in_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'HYDxxKTL-3P Feed-in maximum power', + : , + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_feed_in_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.hydxxktl_3p_grid_frequency-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -5823,6 +5991,286 @@ 'state': '0.0', }) # --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Passive mode grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passive mode grid power', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'passive_mode_grid_power', + 'unique_id': 'SP1XXES100XX_passive_mode_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'HYDxxKTL-3P Passive mode grid power', + : , + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_maximum_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_maximum_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Passive mode maximum battery power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passive mode maximum battery power', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'passive_mode_battery_power_max', + 'unique_id': 'SP1XXES100XX_passive_mode_battery_power_max', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_maximum_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'HYDxxKTL-3P Passive mode maximum battery power', + : , + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_maximum_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_minimum_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_minimum_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Passive mode minimum battery power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passive mode minimum battery power', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'passive_mode_battery_power_min', + 'unique_id': 'SP1XXES100XX_passive_mode_battery_power_min', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_minimum_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'HYDxxKTL-3P Passive mode minimum battery power', + : , + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_minimum_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_timeout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Passive mode timeout', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passive mode timeout', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'passive_mode_timeout', + 'unique_id': 'SP1XXES100XX_passive_mode_timeout', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'HYDxxKTL-3P Passive mode timeout', + : , + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_timeout_action-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'force_standby', + 'return_to_previous_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_timeout_action', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Passive mode timeout action', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passive mode timeout action', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'passive_mode_timeout_action', + 'unique_id': 'SP1XXES100XX_passive_mode_timeout_action', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.hydxxktl_3p_passive_mode_timeout_action-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'HYDxxKTL-3P Passive mode timeout action', + : list([ + 'force_standby', + 'return_to_previous_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.hydxxktl_3p_passive_mode_timeout_action', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'force_standby', + }) +# --- # name: test_all_entities[sensor.hydxxktl_3p_power_factor_output_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 5e37e43f0c1db6..413421cfabcedf 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -102,12 +102,12 @@ async def test_sensor_entities_created_and_state( @pytest.mark.parametrize( ("serial", "model", "seed", "created", "enabled"), [ - pytest.param(MOCK_SERIAL, MOCK_MODEL, seed_pv_inverter, 71, 21, id="pv"), + pytest.param(MOCK_SERIAL, MOCK_MODEL, seed_pv_inverter, 74, 21, id="pv"), pytest.param( MOCK_HYBRID_SERIAL, MOCK_HYBRID_MODEL, seed_hybrid_inverter, - 137, + 145, 44, id="hybrid", ), From c6219719b55b4b1f58e926684ba7504c42a93199 Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:34:00 +0200 Subject: [PATCH 07/15] Add the Sofar active power limit binary sensor (#181198) --- .../components/sofar/binary_sensor.py | 43 ++++++++++++++++ homeassistant/components/sofar/strings.json | 3 ++ .../sofar/snapshots/test_binary_sensor.ambr | 50 +++++++++++++++++++ tests/components/sofar/test_binary_sensor.py | 43 +++++++++++++++- 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sofar/binary_sensor.py b/homeassistant/components/sofar/binary_sensor.py index fc1e215ceff4eb..e0f36e2ef7fc99 100644 --- a/homeassistant/components/sofar/binary_sensor.py +++ b/homeassistant/components/sofar/binary_sensor.py @@ -1,8 +1,10 @@ """Support for Sofar binary sensors.""" from dataclasses import dataclass +from enum import IntFlag from typing import override +from sofar_modbus.modern.enums import PowerControlFlags from sofar_modbus.modern.faults import FaultCategory from homeassistant.components.binary_sensor import ( @@ -52,6 +54,29 @@ class SofarFaultBinarySensorDescription( ) +@dataclass(frozen=True, kw_only=True) +class SofarFlagBinarySensorDescription( + SofarEntityDescription, BinarySensorEntityDescription +): + """Describe a Sofar binary sensor backed by one flags-register bit.""" + + attribute: str + flag: IntFlag + + +FLAG_SENSOR_DESCRIPTIONS: tuple[SofarFlagBinarySensorDescription, ...] = ( + SofarFlagBinarySensorDescription( + key="active_power_limit_enabled", + component="active_power_control", + translation_key="active_power_limit_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + attribute="power_control", + flag=PowerControlFlags.ACTIVE_POWER, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: SofarConfigEntry, @@ -65,6 +90,11 @@ async def async_setup_entry( for description in FAULT_SENSOR_DESCRIPTIONS if description.component in served ) + async_add_entities( + SofarFlagBinarySensor(runtime_data, description) + for description in FLAG_SENSOR_DESCRIPTIONS + if description.component in served + ) class SofarFaultBinarySensor(SofarEntity, BinarySensorEntity): @@ -80,3 +110,16 @@ def is_on(self) -> bool: fault.category is self.entity_description.category for fault in component.active_faults ) + + +class SofarFlagBinarySensor(SofarEntity, BinarySensorEntity): + """Reports whether one bit of a flags register is set.""" + + entity_description: SofarFlagBinarySensorDescription + + @property + @override + def is_on(self) -> bool: + component = getattr(self.coordinator.device, self.entity_description.component) + flags = getattr(component, self.entity_description.attribute) + return self.entity_description.flag in flags diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 027f6413ec912a..a9603df09458f6 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -47,6 +47,9 @@ }, "entity": { "binary_sensor": { + "active_power_limit_enabled": { + "name": "Active power limit enabled" + }, "fault_ac_output": { "name": "AC output fault" }, diff --git a/tests/components/sofar/snapshots/test_binary_sensor.ambr b/tests/components/sofar/snapshots/test_binary_sensor.ambr index 2d17ac76681e40..a147642c4573f2 100644 --- a/tests/components/sofar/snapshots/test_binary_sensor.ambr +++ b/tests/components/sofar/snapshots/test_binary_sensor.ambr @@ -50,6 +50,56 @@ 'state': 'off', }) # --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_active_power_limit_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_active_power_limit_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power limit enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active power limit enabled', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_limit_enabled', + 'unique_id': 'SS2ES104N5S445_active_power_limit_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.4_4_ktlx_g3_active_power_limit_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '4.4 KTLX-G3 Active power limit enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.4_4_ktlx_g3_active_power_limit_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[binary_sensor.4_4_ktlx_g3_arc_fault_afci-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/sofar/test_binary_sensor.py b/tests/components/sofar/test_binary_sensor.py index 60ae99abae99c8..463f8db1da4bea 100644 --- a/tests/components/sofar/test_binary_sensor.py +++ b/tests/components/sofar/test_binary_sensor.py @@ -108,8 +108,9 @@ async def test_enabled_by_default_excludes_commercial_hardware( entity_registry, init_integration.entry_id ) if e.domain == BINARY_SENSOR_DOMAIN + and e.unique_id.startswith(f"{MOCK_SERIAL}_fault_") ] - assert len(entries) == len(FAULT_SENSOR_DESCRIPTIONS) + assert len(entries) == 17 disabled_categories = { FaultCategory(e.unique_id.removeprefix(f"{MOCK_SERIAL}_fault_")) @@ -122,3 +123,43 @@ async def test_enabled_by_default_excludes_commercial_hardware( FaultCategory.INPUT_FUSE, FaultCategory.STRING_FUSE, } + + +@pytest.mark.parametrize( + ("armed", "expected"), + [ + pytest.param(0b0, STATE_OFF, id="not_armed"), + pytest.param(0b1, STATE_ON, id="armed"), + pytest.param(0b10, STATE_OFF, id="unrelated_flag_only"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_active_power_limit_enabled_follows_its_bit( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, + armed: int, + expected: str, +) -> None: + """Test the arm bit of 1105 drives the state, not the limit itself.""" + unit = mock_connection.for_unit(1) + unit.holding[0x1105] = armed + unit.holding[0x1106] = 800 # 80% limit, ignored while the bit is unset + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_active_power_limit_enabled" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + assert state.state == expected From c1bdb2de02da6a12b1fddd27b25fb81859599fe4 Mon Sep 17 00:00:00 2001 From: GhislainC Date: Thu, 3 Sep 2026 20:38:30 +0200 Subject: [PATCH 08/15] Bump pydaikin to 2.19.1 (hotfix) (#181219) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index c86b0ca39acddc..128662ee866f58 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.19.0"], + "requirements": ["pydaikin==2.19.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 55119bdec4cb45..350592bb8504c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2150,7 +2150,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.19.0 +pydaikin==2.19.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 From e1b4ddca891ac753ebec1ca4ce4f625ab89987ca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:47:10 +0200 Subject: [PATCH 09/15] Remove leftover modbus.restart action metadata (#181202) --- homeassistant/components/modbus/const.py | 2 -- homeassistant/components/modbus/icons.json | 3 --- homeassistant/components/modbus/modbus.py | 7 ------- homeassistant/components/modbus/services.yaml | 7 ------- homeassistant/components/modbus/strings.json | 10 ---------- 5 files changed, 29 deletions(-) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index ca4edba211d098..2b0acc6675c785 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -148,11 +148,9 @@ class DataType(StrEnum): SERVICE_WRITE_COIL = "write_coil" SERVICE_WRITE_REGISTER = "write_register" SERVICE_STOP = "stop" -SERVICE_RESTART = "restart" # dispatcher signals SIGNAL_STOP_ENTITY = "modbus.stop" -SIGNAL_START_ENTITY = "modbus.start" # integration names DEFAULT_HUB = "modbus_hub" diff --git a/homeassistant/components/modbus/icons.json b/homeassistant/components/modbus/icons.json index e5940203474ef7..bd2b5a4b32e3f4 100644 --- a/homeassistant/components/modbus/icons.json +++ b/homeassistant/components/modbus/icons.json @@ -3,9 +3,6 @@ "reload": { "service": "mdi:reload" }, - "restart": { - "service": "mdi:restart" - }, "stop": { "service": "mdi:stop" }, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 8ad43545d4807b..2b82af348d60f0 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -349,13 +349,6 @@ async def async_setup(self) -> bool: ) return True - async def async_restart(self) -> None: - """Reconnect client.""" - if self._client: - await self.async_close() - - await self.async_setup() - async def async_close(self) -> None: """Disconnect client.""" self.event_connected.set() diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 8dafa911ada1d1..a786767f240267 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -54,10 +54,3 @@ stop: default: "modbus_hub" selector: text: -restart: - fields: - hub: - example: "hub1" - default: "modbus_hub" - selector: - text: diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index d0d78d726e05ef..5d93f909fe00db 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -26,16 +26,6 @@ "description": "Reloads all Modbus entities.", "name": "[%key:common::action::reload%]" }, - "restart": { - "description": "Restarts a Modbus hub (if running, stops then starts).", - "fields": { - "hub": { - "description": "[%key:component::modbus::services::write_coil::fields::hub::description%]", - "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]" - } - }, - "name": "[%key:common::action::restart%]" - }, "stop": { "description": "Stops a Modbus hub.", "fields": { From 7edac8b13091a17663bfd3f50e00a22f0f8ea9ac Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Thu, 3 Sep 2026 20:52:38 +0200 Subject: [PATCH 10/15] Bump hdate to 1.2.2 (#181189) --- homeassistant/components/jewish_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- .../jewish_calendar/snapshots/test_calendar.ambr | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 0cbb0df787e800..2493dd51c8a1f6 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.2.1"], + "requirements": ["hdate[astral]==1.2.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 350592bb8504c1..16f6c64f44faad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ hass-splunk==0.1.4 hassil==3.12.0 # homeassistant.components.jewish_calendar -hdate[astral]==1.2.1 +hdate[astral]==1.2.2 # homeassistant.components.hdfury hdfury==1.6.1 diff --git a/tests/components/jewish_calendar/snapshots/test_calendar.ambr b/tests/components/jewish_calendar/snapshots/test_calendar.ambr index 585300c186c7e8..88aa9c3a7fb16d 100644 --- a/tests/components/jewish_calendar/snapshots/test_calendar.ambr +++ b/tests/components/jewish_calendar/snapshots/test_calendar.ambr @@ -416,7 +416,7 @@ dict({ 'description': ''' Jewish Holiday: שושן פורים - Holiday Type: HolidayTypes.MELACHA_PERMITTED_HOLIDAY + Holiday Type: חג (מלאכה מותרת) ''', 'end': '2024-03-26', 'start': '2024-03-25', @@ -481,7 +481,7 @@ dict({ 'description': ''' Jewish Holiday: שמחת תורה - Holiday Type: HolidayTypes.YOM_TOV + Holiday Type: יום טוב ''', 'end': '2024-10-26', 'start': '2024-10-25', @@ -506,7 +506,7 @@ dict({ 'description': ''' Jewish Holiday: שמיני עצרת - Holiday Type: HolidayTypes.YOM_TOV + Holiday Type: יום טוב ''', 'end': '2024-10-25', 'start': '2024-10-24', @@ -515,7 +515,7 @@ dict({ 'description': ''' Jewish Holiday: שמחת תורה - Holiday Type: HolidayTypes.YOM_TOV + Holiday Type: יום טוב ''', 'end': '2024-10-25', 'start': '2024-10-24', From 3aae3b4ea795270f7627553b06419892789d8c02 Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:09:36 +0200 Subject: [PATCH 11/15] Drop translations from the Sofar coordinator errors (#181221) --- homeassistant/components/sofar/coordinator.py | 3 --- homeassistant/components/sofar/strings.json | 4 ++-- tests/components/sofar/test_init.py | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sofar/coordinator.py b/homeassistant/components/sofar/coordinator.py index 5ac7ad51e89b44..200743c0b1ed01 100644 --- a/homeassistant/components/sofar/coordinator.py +++ b/homeassistant/components/sofar/coordinator.py @@ -73,12 +73,10 @@ async def _async_update_data(self) -> UpdateReport: raise UpdateFailed( translation_domain=DOMAIN, translation_key="no_component_answered", - translation_placeholders={"name": self.name}, ) raise UpdateFailed( translation_domain=DOMAIN, translation_key="no_component_answered", - translation_placeholders={"name": self.name}, ) from ExceptionGroup("all components failed to refresh", errors) except ModbusError as err: # ModbusConnectionError (dead link) and ModbusTimeoutError reach @@ -86,7 +84,6 @@ async def _async_update_data(self) -> UpdateReport: raise UpdateFailed( translation_domain=DOMAIN, translation_key="modbus_error", - translation_placeholders={"error": str(err)}, ) from err else: return report diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index a9603df09458f6..a9822f170a70ae 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -612,10 +612,10 @@ "message": "Maximum power must be a multiple of 100 W." }, "modbus_error": { - "message": "{error}" + "message": "Error communicating with the inverter." }, "no_component_answered": { - "message": "{name}: no component answered." + "message": "No component answered." }, "unrecognized_inverter_model": { "message": "Unrecognized Sofar inverter model for {title}." diff --git a/tests/components/sofar/test_init.py b/tests/components/sofar/test_init.py index d956782289f51d..f48148ab87c5fe 100644 --- a/tests/components/sofar/test_init.py +++ b/tests/components/sofar/test_init.py @@ -346,7 +346,7 @@ async def test_every_component_failing_recovers_on_a_later_poll( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # Availability alone cannot tell a failed poll from one that reported # every component as failed; only the logged error separates them. - assert "no component answered" in caplog.text + assert "No component answered" in caplog.text mock_connection.for_unit(1).fail_requests(None) freezer.tick(timedelta(seconds=SCAN_INTERVAL)) From 5ea347b93d6fbcc50bdd776039c271e52c83c702 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 3 Sep 2026 21:12:30 +0200 Subject: [PATCH 12/15] Polish sensor code MELCloud Home (#181217) --- homeassistant/components/melcloud_home/sensor.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 796ed8eb92dbf7..8573eeea344bb4 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -45,11 +45,6 @@ ) -def _has_energy_meter(unit: ATAUnit | ATWUnit) -> bool: - """Return whether a unit reports an energy consumption meter.""" - return bool(unit.capabilities and unit.capabilities.has_energy_consumed_meter) - - @dataclass(frozen=True, kw_only=True) class MelCloudHomeSensorEntityDescription[_UnitT: ATAUnit | ATWUnit]( SensorEntityDescription @@ -148,7 +143,9 @@ async def async_setup_entry( ( ATAEnergySensor(coordinator, energy_coordinator, unit) for unit in units - if _has_energy_meter(unit) + if bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ) ), ), lambda units: chain( @@ -161,7 +158,9 @@ async def async_setup_entry( ( ATWEnergySensor(coordinator, energy_coordinator, unit) for unit in units - if _has_energy_meter(unit) + if bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ) ), ), ) From 5643c2ba66cf98a84daf4ab10c92a8ed12d65b9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:22:35 +0200 Subject: [PATCH 13/15] Rename service registration method in google_assistant and shopping_list (#181172) --- homeassistant/components/google_assistant/__init__.py | 4 ++-- homeassistant/components/google_assistant/services.py | 2 +- homeassistant/components/shopping_list/__init__.py | 4 ++-- homeassistant/components/shopping_list/services.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 3c87085929d23b..e0ec33c7a0be0b 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -30,7 +30,7 @@ SOURCE_CLOUD, ) from .http import GoogleAssistantView, GoogleConfig -from .services import async_register_services +from .services import async_setup_services from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip @@ -102,7 +102,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN] if CONF_SERVICE_ACCOUNT in yaml_config[DOMAIN]: - async_register_services(hass) + async_setup_services(hass) hass.async_create_task( hass.config_entries.flow.async_init( diff --git a/homeassistant/components/google_assistant/services.py b/homeassistant/components/google_assistant/services.py index 2c4391558e5dcd..1b30f7ef1d1f77 100644 --- a/homeassistant/components/google_assistant/services.py +++ b/homeassistant/components/google_assistant/services.py @@ -13,7 +13,7 @@ @callback -def async_register_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Register Google Assistant services.""" async def request_sync_service_handler(call: ServiceCall) -> None: diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 2d4bbf324b92a7..3af56179837416 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -22,7 +22,7 @@ _get_shopping_data, ) from .const import DOMAIN -from .services import async_register_services +from .services import async_setup_services PLATFORMS = [Platform.TODO] @@ -33,7 +33,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the shopping list.""" - async_register_services(hass) + async_setup_services(hass) if DOMAIN not in config: return True diff --git a/homeassistant/components/shopping_list/services.py b/homeassistant/components/shopping_list/services.py index 693c0d15b0be96..5224bce10bdd56 100644 --- a/homeassistant/components/shopping_list/services.py +++ b/homeassistant/components/shopping_list/services.py @@ -33,7 +33,7 @@ @callback -def async_register_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Register shopping list services.""" async def add_item_service(call: ServiceCall) -> None: From cca93dec22d3d3523e530c5cfecd9af8e1c9ccf1 Mon Sep 17 00:00:00 2001 From: moryoav Date: Thu, 3 Sep 2026 22:40:18 +0300 Subject: [PATCH 14/15] Fix Besen config flow schema serialization (#181223) --- homeassistant/components/besen/config_flow.py | 14 ++++----- tests/components/besen/test_config_flow.py | 31 ++++++++++++------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/besen/config_flow.py b/homeassistant/components/besen/config_flow.py index 755deaee5335f9..59f9dbded70fa1 100644 --- a/homeassistant/components/besen/config_flow.py +++ b/homeassistant/components/besen/config_flow.py @@ -34,13 +34,10 @@ def _normalize_address(address: str) -> str: return address.strip().upper() -PIN_SCHEMA = vol.All( - selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.PASSWORD, - ) - ), - vol.Match(r"^\d{6}$"), +PIN_SCHEMA = selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD, + ) ) PIN_ONLY_SCHEMA = vol.Schema( @@ -77,6 +74,9 @@ async def _async_validate_input( ) -> str: """Validate setup by logging into the charger.""" + if len(pin) != 6 or not pin.isdecimal(): + raise InvalidAuth("PIN must be exactly 6 digits") + def _ble_device_provider() -> BLEDevice | None: return bluetooth.async_ble_device_from_address( hass, diff --git a/tests/components/besen/test_config_flow.py b/tests/components/besen/test_config_flow.py index a1bbcdc69ab993..d7ac7ddca70964 100644 --- a/tests/components/besen/test_config_flow.py +++ b/tests/components/besen/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import Mock from besen.exceptions import CannotConnect, InvalidAuth +from probatio import to_field_list import pytest -import voluptuous as vol from homeassistant.components.besen.const import DOMAIN from homeassistant.components.bluetooth import BluetoothServiceInfoBleak @@ -12,6 +12,7 @@ from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv from .conftest import ( FIXTURE_ADDRESS, @@ -91,6 +92,7 @@ async def test_bluetooth_step_sets_discovered_context( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" + assert to_field_list(result["data_schema"], custom_serializer=cv.custom_serializer) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -243,25 +245,32 @@ async def test_user_step_success( _assert_create_entry(result) -@pytest.mark.usefixtures("mock_besen_client", "mock_setup_entry") +@pytest.mark.parametrize("invalid_pin", ["12345", "¹²³⁴⁵⁶"]) +@pytest.mark.usefixtures("mock_setup_entry") async def test_user_step_rejects_invalid_pin( hass: HomeAssistant, + mock_besen_client: Mock, + invalid_pin: str, ) -> None: - """Test the user step PIN schema rejects invalid values.""" + """Test the user step rejects invalid PIN values.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - with pytest.raises(vol.Invalid): - await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_ADDRESS: FIXTURE_ADDRESS, - CONF_PIN: "12345", - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: FIXTURE_ADDRESS, + CONF_PIN: invalid_pin, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + mock_besen_client.async_start.assert_not_awaited() result = await hass.config_entries.flow.async_configure( result["flow_id"], From 357c6f56162a0269e86a36aa1ad66d6af222c282 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 3 Sep 2026 22:25:29 +0200 Subject: [PATCH 15/15] Fix EnergyZero market price regression (#181230) --- .../components/energyzero/coordinator.py | 4 ++ .../snapshots/test_diagnostics.ambr | 24 +++++----- .../energyzero/snapshots/test_sensor.ambr | 18 ++++---- tests/components/energyzero/test_init.py | 46 ++++++++++++++++++- tests/components/energyzero/test_sensor.py | 9 ++++ 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 700df0f6c3aad5..783469c293fdb8 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -10,6 +10,7 @@ EnergyZeroConnectionError, EnergyZeroNoDataError, Interval, + PriceType, ) from homeassistant.config_entries import ConfigEntry @@ -61,12 +62,14 @@ async def _async_update_data(self) -> EnergyZeroData: start_date=today, end_date=today, interval=Interval.HOUR, + price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ) try: gas_today = await self.energyzero.get_gas_prices( start_date=today, end_date=today, + price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ) except EnergyZeroNoDataError: @@ -79,6 +82,7 @@ async def _async_update_data(self) -> EnergyZeroData: start_date=tomorrow, end_date=tomorrow, interval=Interval.HOUR, + price_type=PriceType.MARKET_WITH_VAT, local_tz=local_tz, ) except EnergyZeroNoDataError: diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index da9d3b586e1bec..26d3533d84cac3 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -2,15 +2,15 @@ # name: test_diagnostics_no_gas_today dict({ 'energy': dict({ - 'average_price': 0.25694034895833334, - 'current_hour_price': 0.28275885, + 'average_price': 0.14609224895833334, + 'current_hour_price': 0.17191075, 'highest_price_time': '2026-04-10T18:00:00+00:00', 'hours_priced_equal_or_lower': 20, 'lowest_price_time': '2026-04-11T06:00:00+00:00', - 'max_price': 0.399000525, - 'min_price': 0.188351625, - 'next_hour_price': 0.2629693, - 'percentage_of_max': 70.87, + 'max_price': 0.288152425, + 'min_price': 0.077503525, + 'next_hour_price': 0.1521212, + 'percentage_of_max': 59.66, }), 'entry': dict({ 'title': 'energy', @@ -24,15 +24,15 @@ # name: test_entry_diagnostics dict({ 'energy': dict({ - 'average_price': 0.25694034895833334, - 'current_hour_price': 0.28275885, + 'average_price': 0.14609224895833334, + 'current_hour_price': 0.17191075, 'highest_price_time': '2026-04-10T18:00:00+00:00', 'hours_priced_equal_or_lower': 20, 'lowest_price_time': '2026-04-11T06:00:00+00:00', - 'max_price': 0.399000525, - 'min_price': 0.188351625, - 'next_hour_price': 0.2629693, - 'percentage_of_max': 70.87, + 'max_price': 0.288152425, + 'min_price': 0.077503525, + 'next_hour_price': 0.1521212, + 'percentage_of_max': 59.66, }), 'entry': dict({ 'title': 'energy', diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 31f823bc90f67b..cf33cc9dac121f 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -51,7 +51,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.256940348958333', + 'state': '0.141981021875', }) # --- # name: test_sensor[sensor.energyzero_today_energy_current_hour_price-entry] @@ -109,7 +109,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.28275885', + 'state': '0.17191075', }) # --- # name: test_sensor[sensor.energyzero_today_energy_highest_price_time-entry] @@ -265,7 +265,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2026-04-11T06:00:00+00:00', + 'state': '2026-04-10T01:00:00+00:00', }) # --- # name: test_sensor[sensor.energyzero_today_energy_max_price-entry] @@ -320,7 +320,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.399000525', + 'state': '0.288152425', }) # --- # name: test_sensor[sensor.energyzero_today_energy_min_price-entry] @@ -375,7 +375,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.188351625', + 'state': '0.08713815', }) # --- # name: test_sensor[sensor.energyzero_today_energy_next_hour_price-entry] @@ -430,7 +430,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.2629693', + 'state': '0.1521212', }) # --- # name: test_sensor[sensor.energyzero_today_energy_percentage_of_max-entry] @@ -482,7 +482,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '70.87', + 'state': '59.66', }) # --- # name: test_sensor[sensor.energyzero_today_gas_current_hour_price-entry] @@ -540,7 +540,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.5468407201224', }) # --- # name: test_sensor[sensor.energyzero_today_gas_next_hour_price-entry] @@ -595,6 +595,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.5468407201224', }) # --- diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index c14d440f62e9cc..03b23c63470107 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -1,8 +1,10 @@ """Tests for the EnergyZero integration.""" -from unittest.mock import MagicMock, patch +from datetime import date +from unittest.mock import MagicMock, call, patch +from zoneinfo import ZoneInfo -from energyzero import EnergyZeroConnectionError +from energyzero import EnergyZeroConnectionError, Interval, PriceType import pytest from homeassistant.config_entries import ConfigEntryState @@ -11,6 +13,46 @@ from tests.common import MockConfigEntry +@pytest.mark.freeze_time("2026-04-10 20:32:59") +async def test_coordinator_requests_market_prices_with_vat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_energyzero: MagicMock, +) -> None: + """Test the coordinator requests the backwards-compatible price stream.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + local_tz = ZoneInfo(hass.config.time_zone) + today = date(2026, 4, 10) + tomorrow = date(2026, 4, 11) + mock_energyzero.get_electricity_prices.assert_has_awaits( + [ + call( + start_date=today, + end_date=today, + interval=Interval.HOUR, + price_type=PriceType.MARKET_WITH_VAT, + local_tz=local_tz, + ), + call( + start_date=tomorrow, + end_date=tomorrow, + interval=Interval.HOUR, + price_type=PriceType.MARKET_WITH_VAT, + local_tz=local_tz, + ), + ] + ) + mock_energyzero.get_gas_prices.assert_awaited_once_with( + start_date=today, + end_date=today, + price_type=PriceType.MARKET_WITH_VAT, + local_tz=local_tz, + ) + + @pytest.mark.usefixtures("mock_energyzero") async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 876edecc626a4d..7222870aa06cdf 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -29,9 +29,18 @@ async def test_sensor( snapshot: SnapshotAssertion, ) -> None: """Test the EnergyZero - Energy sensors.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") with patch("homeassistant.components.energyzero.PLATFORMS", ["sensor"]): await setup_integration(hass, mock_config_entry) + gas_state = hass.states.get("sensor.energyzero_today_gas_current_hour_price") + assert gas_state + assert gas_state.state == "0.5468407201224" + + energy_state = hass.states.get("sensor.energyzero_today_energy_current_hour_price") + assert energy_state + assert energy_state.state == "0.17191075" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)