diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3420bbb174c3ef..3d0e2bfd8f80a4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -45,16 +45,16 @@ jobs: - name: Get information id: info - uses: home-assistant/actions/helpers/info@master + uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses] - name: Get version id: version - uses: home-assistant/actions/helpers/version@master + uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses] with: type: ${{ env.BUILD_TYPE }} - name: Verify version - uses: home-assistant/actions/helpers/verify-version@master + uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses] with: ignore-dev: true @@ -316,9 +316,8 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - # home-assistant/builder doesn't support sha pinning - name: Build base image - uses: home-assistant/builder@2025.11.0 + uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0 with: args: | $BUILD_ARGS \ @@ -341,14 +340,14 @@ jobs: persist-credentials: false - name: Initialize git - uses: home-assistant/actions/helpers/git-init@master + uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses] with: name: ${{ secrets.GIT_NAME }} email: ${{ secrets.GIT_EMAIL }} token: ${{ secrets.GIT_TOKEN }} - name: Update version file - uses: home-assistant/actions/helpers/version-push@master + uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses] with: key: "homeassistant[]" key-description: "Home Assistant Core" @@ -358,7 +357,7 @@ jobs: - name: Update version file (stable -> beta) if: needs.init.outputs.channel == 'stable' - uses: home-assistant/actions/helpers/version-push@master + uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses] with: key: "homeassistant[]" key-description: "Home Assistant Core" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ba44a6636e714..dc89d0c027b4f4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -309,7 +309,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/hadolint.json" - name: Check ${{ matrix.file }} - uses: docker://hadolint/hadolint:v2.12.0 + uses: docker://hadolint/hadolint:v2.12.0@sha256:30a8fd2e785ab6176eed53f74769e04f125afb2f74a6c52aef7d463583b6d45e with: args: hadolint ${{ matrix.file }} @@ -1039,7 +1039,7 @@ jobs: contents: read services: mariadb: - image: ${{ matrix.mariadb-group }} + image: ${{ matrix.mariadb-group }} # zizmor: ignore[unpinned-images] ports: - 3306:3306 env: @@ -1197,7 +1197,7 @@ jobs: contents: read services: postgres: - image: ${{ matrix.postgresql-group }} + image: ${{ matrix.postgresql-group }} # zizmor: ignore[unpinned-images] ports: - 5432:5432 env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d8ce3b83f117e4..8e34dfef04ddc3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 with: category: "/language:python" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index e82fc715156c1a..ab87db24ab82c4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.13.0", "openai==2.21.0"], + "requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"], "single_config_entry": true } diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 16d8158f7e2d6f..acd3b9f9d1fcbf 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -15,7 +15,7 @@ from .const import DOMAIN STORAGE_VERSION = 1 -STORAGE_MINOR_VERSION = 2 +STORAGE_MINOR_VERSION = 3 STORAGE_KEY = DOMAIN @@ -92,8 +92,11 @@ class GridPowerSourceType(TypedDict, total=False): power_config: PowerConfig -class GridSourceType(TypedDict): - """Dictionary holding the source of grid energy consumption.""" +class LegacyGridSourceType(TypedDict): + """Legacy dictionary holding the source of grid energy consumption. + + This format is deprecated and will be migrated to GridSourceType. + """ type: Literal["grid"] @@ -104,6 +107,40 @@ class GridSourceType(TypedDict): cost_adjustment_day: float +class GridSourceType(TypedDict): + """Dictionary holding a unified grid connection (like batteries). + + Each grid connection represents a single import/export pair with + optional power tracking. Multiple grid sources are allowed. + """ + + type: Literal["grid"] + + # Import meter - kWh consumed from grid + # Can be None for export-only or power-only grids migrated from legacy format + stat_energy_from: str | None + + # Export meter (optional) - kWh returned to grid (solar/battery export) + stat_energy_to: str | None + + # Cost tracking for import + stat_cost: str | None # statistic_id of costs ($) incurred + entity_energy_price: str | None # entity_id providing price ($/kWh) + number_energy_price: float | None # Fixed price ($/kWh) + + # Compensation tracking for export + stat_compensation: str | None # statistic_id of compensation ($) received + entity_energy_price_export: str | None # entity_id providing export price ($/kWh) + number_energy_price_export: float | None # Fixed export price ($/kWh) + + # Power measurement (optional) + # positive when consuming from grid, negative when exporting + stat_rate: NotRequired[str] + power_config: NotRequired[PowerConfig] + + cost_adjustment_day: float + + class SolarSourceType(TypedDict): """Dictionary holding the source of energy production.""" @@ -308,23 +345,77 @@ def validate_uniqueness( return validate_uniqueness -GRID_SOURCE_SCHEMA = vol.Schema( - { - vol.Required("type"): "grid", - vol.Required("flow_from"): vol.All( - [FLOW_FROM_GRID_SOURCE_SCHEMA], - _generate_unique_value_validator("stat_energy_from"), - ), - vol.Required("flow_to"): vol.All( - [FLOW_TO_GRID_SOURCE_SCHEMA], - _generate_unique_value_validator("stat_energy_to"), - ), - vol.Optional("power"): vol.All( - [GRID_POWER_SOURCE_SCHEMA], - _generate_unique_value_validator("stat_rate"), - ), - vol.Required("cost_adjustment_day"): vol.Coerce(float), - } +def _grid_ensure_single_price_import( + val: dict[str, Any], +) -> dict[str, Any]: + """Ensure we use a single price source for import.""" + if ( + val.get("entity_energy_price") is not None + and val.get("number_energy_price") is not None + ): + raise vol.Invalid("Define either an entity or a fixed number for import price") + return val + + +def _grid_ensure_single_price_export( + val: dict[str, Any], +) -> dict[str, Any]: + """Ensure we use a single price source for export.""" + if ( + val.get("entity_energy_price_export") is not None + and val.get("number_energy_price_export") is not None + ): + raise vol.Invalid("Define either an entity or a fixed number for export price") + return val + + +def _grid_ensure_at_least_one_stat( + val: dict[str, Any], +) -> dict[str, Any]: + """Ensure at least one of import, export, or power is configured.""" + if ( + val.get("stat_energy_from") is None + and val.get("stat_energy_to") is None + and val.get("stat_rate") is None + and val.get("power_config") is None + ): + raise vol.Invalid( + "Grid must have at least one of: import meter, export meter, or power sensor" + ) + return val + + +GRID_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("type"): "grid", + # Import meter (can be None for export-only grids from legacy migration) + vol.Optional("stat_energy_from", default=None): vol.Any(str, None), + # Export meter (optional) + vol.Optional("stat_energy_to", default=None): vol.Any(str, None), + # Import cost tracking + vol.Optional("stat_cost", default=None): vol.Any(str, None), + vol.Optional("entity_energy_price", default=None): vol.Any(str, None), + vol.Optional("number_energy_price", default=None): vol.Any( + vol.Coerce(float), None + ), + # Export compensation tracking + vol.Optional("stat_compensation", default=None): vol.Any(str, None), + vol.Optional("entity_energy_price_export", default=None): vol.Any( + str, None + ), + vol.Optional("number_energy_price_export", default=None): vol.Any( + vol.Coerce(float), None + ), + # Power measurement (optional) + vol.Optional("stat_rate"): str, + vol.Optional("power_config"): POWER_CONFIG_SCHEMA, + vol.Required("cost_adjustment_day"): vol.Coerce(float), + } + ), + _grid_ensure_single_price_import, + _grid_ensure_single_price_export, + _grid_ensure_at_least_one_stat, ) SOLAR_SOURCE_SCHEMA = vol.Schema( { @@ -369,10 +460,46 @@ def validate_uniqueness( def check_type_limits(value: list[SourceType]) -> list[SourceType]: """Validate that we don't have too many of certain types.""" - types = Counter([val["type"] for val in value]) + # Currently no type limits - multiple grid sources are allowed (like batteries) + return value + + +def _validate_grid_stat_uniqueness(value: list[SourceType]) -> list[SourceType]: + """Validate that grid statistics are unique across all sources.""" + seen_import: set[str] = set() + seen_export: set[str] = set() + seen_rate: set[str] = set() + + for source in value: + if source.get("type") != "grid": + continue + + # Cast to GridSourceType since we've filtered for grid type + grid_source: GridSourceType = source # type: ignore[assignment] + + # Check import meter uniqueness + if (stat_from := grid_source.get("stat_energy_from")) is not None: + if stat_from in seen_import: + raise vol.Invalid( + f"Import meter {stat_from} is used in multiple grid connections" + ) + seen_import.add(stat_from) - if types.get("grid", 0) > 1: - raise vol.Invalid("You cannot have more than 1 grid source") + # Check export meter uniqueness + if (stat_to := grid_source.get("stat_energy_to")) is not None: + if stat_to in seen_export: + raise vol.Invalid( + f"Export meter {stat_to} is used in multiple grid connections" + ) + seen_export.add(stat_to) + + # Check power stat uniqueness + if (stat_rate := grid_source.get("stat_rate")) is not None: + if stat_rate in seen_rate: + raise vol.Invalid( + f"Power stat {stat_rate} is used in multiple grid connections" + ) + seen_rate.add(stat_rate) return value @@ -393,6 +520,7 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]: ] ), check_type_limits, + _validate_grid_stat_uniqueness, ) DEVICE_CONSUMPTION_SCHEMA = vol.Schema( @@ -405,6 +533,82 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]: ) +def _migrate_legacy_grid_to_unified( + old_grid: dict[str, Any], +) -> list[dict[str, Any]]: + """Migrate legacy grid format (flow_from/flow_to/power arrays) to unified format. + + Each grid connection can have any combination of import, export, and power - + all are optional as long as at least one is configured. + + Migration pairs arrays by index position: + - flow_from[i], flow_to[i], and power[i] combine into grid connection i + - If arrays have different lengths, missing entries get None for that field + - The number of grid connections equals max(len(flow_from), len(flow_to), len(power)) + """ + flow_from = old_grid.get("flow_from", []) + flow_to = old_grid.get("flow_to", []) + power_list = old_grid.get("power", []) + cost_adj = old_grid.get("cost_adjustment_day", 0.0) + + new_sources: list[dict[str, Any]] = [] + # Number of grid connections = max length across all three arrays + # If all arrays are empty, don't create any grid sources + max_len = max(len(flow_from), len(flow_to), len(power_list)) + if max_len == 0: + return [] + + for i in range(max_len): + source: dict[str, Any] = { + "type": "grid", + "cost_adjustment_day": cost_adj, + } + + # Import fields from flow_from + if i < len(flow_from): + ff = flow_from[i] + source["stat_energy_from"] = ff.get("stat_energy_from") or None + source["stat_cost"] = ff.get("stat_cost") + source["entity_energy_price"] = ff.get("entity_energy_price") + source["number_energy_price"] = ff.get("number_energy_price") + else: + # Export-only entry - set import to None (validation will flag this) + source["stat_energy_from"] = None + source["stat_cost"] = None + source["entity_energy_price"] = None + source["number_energy_price"] = None + + # Export fields from flow_to + if i < len(flow_to): + ft = flow_to[i] + source["stat_energy_to"] = ft.get("stat_energy_to") + source["stat_compensation"] = ft.get("stat_compensation") + source["entity_energy_price_export"] = ft.get("entity_energy_price") + source["number_energy_price_export"] = ft.get("number_energy_price") + else: + source["stat_energy_to"] = None + source["stat_compensation"] = None + source["entity_energy_price_export"] = None + source["number_energy_price_export"] = None + + # Power config at index i goes to grid connection at index i + if i < len(power_list): + power = power_list[i] + if "power_config" in power: + source["power_config"] = power["power_config"] + if "stat_rate" in power: + source["stat_rate"] = power["stat_rate"] + + new_sources.append(source) + + return new_sources + + +def _is_legacy_grid_format(source: dict[str, Any]) -> bool: + """Check if a grid source is in the legacy format.""" + return source.get("type") == "grid" and "flow_from" in source + + class _EnergyPreferencesStore(storage.Store[EnergyPreferences]): """Energy preferences store with migration support.""" @@ -419,6 +623,18 @@ async def _async_migrate_func( if old_major_version == 1 and old_minor_version < 2: # Add device_consumption_water field if it doesn't exist data.setdefault("device_consumption_water", []) + + if old_major_version == 1 and old_minor_version < 3: + # Migrate legacy grid format to unified format + new_sources: list[dict[str, Any]] = [] + for source in data.get("energy_sources", []): + if _is_legacy_grid_format(source): + # Convert legacy grid to multiple unified grid sources + new_sources.extend(_migrate_legacy_grid_to_unified(source)) + else: + new_sources.append(source) + data["energy_sources"] = new_sources + return data @@ -516,27 +732,18 @@ def _process_grid_power( source: GridSourceType, generate_entity_id: Callable[[str, PowerConfig], str], ) -> GridSourceType: - """Set stat_rate for grid power sources if power_config is specified.""" - if "power" not in source: + """Set stat_rate for grid if power_config is specified.""" + if "power_config" not in source: return source - processed_power: list[GridPowerSourceType] = [] - for power in source["power"]: - if "power_config" in power: - config = power["power_config"] + config = source["power_config"] - # If power_config has stat_rate (standard), just use it directly - if "stat_rate" in config: - processed_power.append({**power, "stat_rate": config["stat_rate"]}) - else: - # For inverted or two-sensor config, set stat_rate to generated entity_id - processed_power.append( - {**power, "stat_rate": generate_entity_id("grid", config)} - ) - else: - processed_power.append(power) - - return {**source, "power": processed_power} + # If power_config has stat_rate (standard), just use it directly + if "stat_rate" in config: + return {**source, "stat_rate": config["stat_rate"]} + + # For inverted or two-sensor config, set stat_rate to the generated entity_id + return {**source, "stat_rate": generate_entity_id("grid", config)} @callback def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None: diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 3a512dc5211846..e228e11d00d777 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -94,22 +94,15 @@ class SourceAdapter: SOURCE_ADAPTERS: Final = ( + # Grid import cost (unified format) SourceAdapter( "grid", - "flow_from", + None, # No flow_type - unified format "stat_energy_from", "stat_cost", "Cost", "cost", ), - SourceAdapter( - "grid", - "flow_to", - "stat_energy_to", - "stat_compensation", - "Compensation", - "compensation", - ), SourceAdapter( "gas", None, @@ -128,6 +121,16 @@ class SourceAdapter: ), ) +# Separate adapter for grid export compensation (needs different price field) +GRID_EXPORT_ADAPTER: Final = SourceAdapter( + "grid", + None, # No flow_type - unified format + "stat_energy_to", + "stat_compensation", + "Compensation", + "compensation", +) + class EntityNotFoundError(HomeAssistantError): """When a referenced entity was not found.""" @@ -183,22 +186,20 @@ async def finish() -> None: if adapter.source_type != energy_source["type"]: continue - if adapter.flow_type is None: - self._process_sensor_data( - adapter, - energy_source, - to_add, - to_remove, - ) - continue + self._process_sensor_data( + adapter, + energy_source, + to_add, + to_remove, + ) - for flow in energy_source[adapter.flow_type]: # type: ignore[typeddict-item] - self._process_sensor_data( - adapter, - flow, - to_add, - to_remove, - ) + # Handle grid export compensation (unified format uses different price fields) + if energy_source["type"] == "grid": + self._process_grid_export_sensor( + energy_source, + to_add, + to_remove, + ) # Process power sensors for battery and grid sources self._process_power_sensor_data( @@ -222,11 +223,16 @@ def _process_sensor_data( if config.get(adapter.total_money_key) is not None: return - key = (adapter.source_type, adapter.flow_type, config[adapter.stat_energy_key]) + # Skip if the energy stat is not configured (e.g., export-only or power-only grids) + stat_energy = config.get(adapter.stat_energy_key) + if not stat_energy: + return + + key = (adapter.source_type, adapter.flow_type, stat_energy) # Make sure the right data is there # If the entity existed, we don't pop it from to_remove so it's removed - if not valid_entity_id(config[adapter.stat_energy_key]) or ( + if not valid_entity_id(stat_energy) or ( config.get("entity_energy_price") is None and config.get("number_energy_price") is None ): @@ -242,6 +248,56 @@ def _process_sensor_data( ) to_add.append(self.current_entities[key]) + @callback + def _process_grid_export_sensor( + self, + config: Mapping[str, Any], + to_add: list[EnergyCostSensor | EnergyPowerSensor], + to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], + ) -> None: + """Process grid export compensation sensor (unified format). + + The unified grid format uses different field names for export pricing: + - entity_energy_price_export instead of entity_energy_price + - number_energy_price_export instead of number_energy_price + """ + # No export meter configured + stat_energy_to = config.get("stat_energy_to") + if stat_energy_to is None: + return + + # Already have a compensation stat + if config.get("stat_compensation") is not None: + return + + key = ("grid", None, stat_energy_to) + + # Check for export pricing fields (different names in unified format) + if not valid_entity_id(stat_energy_to) or ( + config.get("entity_energy_price_export") is None + and config.get("number_energy_price_export") is None + ): + return + + # Create a config wrapper that maps the sell price fields to standard names + # so EnergyCostSensor can use them + export_config: dict[str, Any] = { + "stat_energy_to": stat_energy_to, + "stat_compensation": config.get("stat_compensation"), + "entity_energy_price": config.get("entity_energy_price_export"), + "number_energy_price": config.get("number_energy_price_export"), + } + + if current_entity := to_remove.pop(key, None): + current_entity.update_config(export_config) + return + + self.current_entities[key] = EnergyCostSensor( + GRID_EXPORT_ADAPTER, + export_config, + ) + to_add.append(self.current_entities[key]) + @callback def _process_power_sensor_data( self, @@ -252,21 +308,14 @@ def _process_power_sensor_data( """Process power sensor data for battery and grid sources.""" source_type = energy_source.get("type") - if source_type == "battery": + if source_type in ("battery", "grid"): + # Both battery and grid now use unified format with power_config at top level power_config = energy_source.get("power_config") if power_config and self._needs_power_sensor(power_config): self._create_or_keep_power_sensor( source_type, power_config, to_add, to_remove ) - elif source_type == "grid": - for power in energy_source.get("power", []): - power_config = power.get("power_config") - if power_config and self._needs_power_sensor(power_config): - self._create_or_keep_power_sensor( - source_type, power_config, to_add, to_remove - ) - @staticmethod def _needs_power_sensor(power_config: PowerConfig) -> bool: """Check if power_config needs a transform sensor.""" @@ -312,6 +361,17 @@ class EnergyCostSensor(SensorEntity): This is intended as a fallback for when no specific cost sensor is available for the utility. + + Expected config fields (from adapter or export_config wrapper): + - stat_energy_key (via adapter): Key to get the energy statistic ID + - total_money_key (via adapter): Key to get the existing cost/compensation stat + - entity_energy_price: Entity ID providing price per unit (e.g., $/kWh) + - number_energy_price: Fixed price per unit + + Note: For grid export compensation, the unified format uses different field names + (entity_energy_price_export, number_energy_price_export). The _process_grid_export_sensor + method in SensorManager creates a wrapper config that maps these to the standard + field names (entity_energy_price, number_energy_price) so this class can use them. """ _attr_entity_registry_visible_default = False diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0508da5295f582..e8d27b14614826 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -401,16 +401,20 @@ def _validate_grid_source( source_result: ValidationIssues, validate_calls: list[functools.partial[None]], ) -> None: - """Validate grid energy source.""" - flow_from: data.FlowFromGridSourceType - for flow_from in source["flow_from"]: - wanted_statistics_metadata.add(flow_from["stat_energy_from"]) + """Validate grid energy source (unified format).""" + stat_energy_from = source.get("stat_energy_from") + stat_energy_to = source.get("stat_energy_to") + stat_rate = source.get("stat_rate") + + # Validate import meter (optional) + if stat_energy_from: + wanted_statistics_metadata.add(stat_energy_from) validate_calls.append( functools.partial( _async_validate_usage_stat, hass, statistics_metadata, - flow_from["stat_energy_from"], + stat_energy_from, ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, @@ -418,7 +422,8 @@ def _validate_grid_source( ) ) - if (stat_cost := flow_from.get("stat_cost")) is not None: + # Validate import cost tracking (only if import meter exists) + if (stat_cost := source.get("stat_cost")) is not None: wanted_statistics_metadata.add(stat_cost) validate_calls.append( functools.partial( @@ -429,7 +434,7 @@ def _validate_grid_source( source_result, ) ) - elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None: + elif (entity_energy_price := source.get("entity_energy_price")) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, @@ -442,27 +447,27 @@ def _validate_grid_source( ) if ( - flow_from.get("entity_energy_price") is not None - or flow_from.get("number_energy_price") is not None + source.get("entity_energy_price") is not None + or source.get("number_energy_price") is not None ): validate_calls.append( functools.partial( _async_validate_auto_generated_cost_entity, hass, - flow_from["stat_energy_from"], + stat_energy_from, source_result, ) ) - flow_to: data.FlowToGridSourceType - for flow_to in source["flow_to"]: - wanted_statistics_metadata.add(flow_to["stat_energy_to"]) + # Validate export meter (optional) + if stat_energy_to: + wanted_statistics_metadata.add(stat_energy_to) validate_calls.append( functools.partial( _async_validate_usage_stat, hass, statistics_metadata, - flow_to["stat_energy_to"], + stat_energy_to, ENERGY_USAGE_DEVICE_CLASSES, ENERGY_USAGE_UNITS, ENERGY_UNIT_ERROR, @@ -470,7 +475,8 @@ def _validate_grid_source( ) ) - if (stat_compensation := flow_to.get("stat_compensation")) is not None: + # Validate export compensation tracking + if (stat_compensation := source.get("stat_compensation")) is not None: wanted_statistics_metadata.add(stat_compensation) validate_calls.append( functools.partial( @@ -481,12 +487,14 @@ def _validate_grid_source( source_result, ) ) - elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None: + elif ( + entity_price_export := source.get("entity_energy_price_export") + ) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, hass, - entity_energy_price, + entity_price_export, source_result, ENERGY_PRICE_UNITS, ENERGY_PRICE_UNIT_ERROR, @@ -494,26 +502,27 @@ def _validate_grid_source( ) if ( - flow_to.get("entity_energy_price") is not None - or flow_to.get("number_energy_price") is not None + source.get("entity_energy_price_export") is not None + or source.get("number_energy_price_export") is not None ): validate_calls.append( functools.partial( _async_validate_auto_generated_cost_entity, hass, - flow_to["stat_energy_to"], + stat_energy_to, source_result, ) ) - for power_stat in source.get("power", []): - wanted_statistics_metadata.add(power_stat["stat_rate"]) + # Validate power sensor (optional) + if stat_rate: + wanted_statistics_metadata.add(stat_rate) validate_calls.append( functools.partial( _async_validate_power_stat, hass, statistics_metadata, - power_stat["stat_rate"], + stat_rate, POWER_USAGE_DEVICE_CLASSES, POWER_USAGE_UNITS, POWER_UNIT_ERROR, diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index f6d6df98054e19..f99861ba7342f6 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.3"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"] } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 75bcf8b9704f75..260f81303aab22 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==3.14.0", + "xknx==3.15.0", "xknxproject==3.8.2", "knx-frontend==2026.2.13.222258" ], diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 1f01c9c78feb76..1aa75aa1141aac 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -45,6 +45,7 @@ class TelegramDict(DecodedTelegramPayload): """Represent a Telegram as a dict.""" # this has to be in sync with the frontend implementation + data_secure: bool | None destination: str destination_name: str direction: str @@ -153,6 +154,7 @@ def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: value = _serializable_decoded_data(telegram.decoded_data.value) return TelegramDict( + data_secure=telegram.data_secure, destination=f"{telegram.destination_address}", destination_name=dst_name, direction=telegram.direction.value, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index dbeaca1b27afa9..0ee853979342e8 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==12.1.3"] + "requirements": ["ical==13.2.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 16e24217a1ba35..c2b68b366e5ad2 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==12.1.3"] + "requirements": ["ical==13.2.0"] } diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index ed93701feca4d6..6b1db79e269fbc 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -35,13 +35,9 @@ ) from homeassistant.const import ( CONF_BRIGHTNESS, - CONF_COLOR_TEMP, CONF_EFFECT, - CONF_HS, CONF_NAME, CONF_OPTIMISTIC, - CONF_RGB, - CONF_XY, STATE_ON, ) from homeassistant.core import callback @@ -55,7 +51,6 @@ from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( - CONF_COLOR_MODE, CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, CONF_EFFECT_LIST, @@ -96,7 +91,7 @@ DEFAULT_FLASH = True DEFAULT_TRANSITION = True -_PLATFORM_SCHEMA_BASE = ( +PLATFORM_SCHEMA_MODERN_JSON = ( MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, @@ -139,24 +134,8 @@ .extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema) ) -# Support for legacy color_mode handling was removed with HA Core 2025.3 -# The removed attributes can be removed from the schema's from HA Core 2026.3 DISCOVERY_SCHEMA_JSON = vol.All( - cv.removed(CONF_COLOR_MODE, raise_if_present=False), - cv.removed(CONF_COLOR_TEMP, raise_if_present=False), - cv.removed(CONF_HS, raise_if_present=False), - cv.removed(CONF_RGB, raise_if_present=False), - cv.removed(CONF_XY, raise_if_present=False), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN_JSON = vol.All( - cv.removed(CONF_COLOR_MODE), - cv.removed(CONF_COLOR_TEMP), - cv.removed(CONF_HS), - cv.removed(CONF_RGB), - cv.removed(CONF_XY), - _PLATFORM_SCHEMA_BASE, + PLATFORM_SCHEMA_MODERN_JSON.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 9c8dbacb1b8d6b..a834106543aec3 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==12.1.3"] + "requirements": ["ical==13.2.0"] } diff --git a/homeassistant/components/xbox/config_flow.py b/homeassistant/components/xbox/config_flow.py index 5ca58210f18510..bba4e36e03327f 100644 --- a/homeassistant/components/xbox/config_flow.py +++ b/homeassistant/components/xbox/config_flow.py @@ -4,7 +4,6 @@ import logging from typing import Any -from httpx import AsyncClient from pythonxbox.api.client import XboxLiveClient from pythonxbox.authentication.manager import AuthenticationManager from pythonxbox.authentication.models import OAuth2TokenResponse @@ -20,6 +19,7 @@ ) from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -67,14 +67,14 @@ async def async_step_user( async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for the flow.""" - async with AsyncClient() as session: - auth = AuthenticationManager(session, "", "", "") - auth.oauth = OAuth2TokenResponse(**data["token"]) - await auth.refresh_tokens() + session = get_async_client(self.hass) + auth = AuthenticationManager(session, "", "", "") + auth.oauth = OAuth2TokenResponse(**data["token"]) + await auth.refresh_tokens() - client = XboxLiveClient(auth) + client = XboxLiveClient(auth) - me = await client.people.get_friends_by_xuid(client.xuid) + me = await client.people.get_friends_by_xuid(client.xuid) await self.async_set_unique_id(client.xuid) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a518adf63105cf..4607f0cca0dd79 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ fnv-hash-fast==1.6.0 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 habluetooth==5.8.0 -hass-nabucasa==1.13.0 +hass-nabucasa==1.15.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20260128.6 diff --git a/pyproject.toml b/pyproject.toml index 1c164aef2ca156..fd4d2cf1e13b4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "fnv-hash-fast==1.6.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.13.0", + "hass-nabucasa==1.15.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index aa942e94b8931f..ab83f697a82556 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ cronsim==2.7 cryptography==46.0.5 fnv-hash-fast==1.6.0 ha-ffmpeg==3.2.2 -hass-nabucasa==1.13.0 +hass-nabucasa==1.15.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 home-assistant-intents==2026.2.13 diff --git a/requirements_all.txt b/requirements_all.txt index 850adbe2acabf3..1c887daba6565e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1179,7 +1179,7 @@ habluetooth==5.8.0 hanna-cloud==0.0.7 # homeassistant.components.cloud -hass-nabucasa==1.13.0 +hass-nabucasa==1.15.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1268,7 +1268,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==12.1.3 +ical==13.2.0 # homeassistant.components.caldav icalendar==6.3.1 @@ -3258,7 +3258,7 @@ wyoming==1.7.2 xiaomi-ble==1.6.0 # homeassistant.components.knx -xknx==3.14.0 +xknx==3.15.0 # homeassistant.components.knx xknxproject==3.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa1a22b48cc414..6dec065ebda1e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1049,7 +1049,7 @@ habluetooth==5.8.0 hanna-cloud==0.0.7 # homeassistant.components.cloud -hass-nabucasa==1.13.0 +hass-nabucasa==1.15.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1123,7 +1123,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==12.1.3 +ical==13.2.0 # homeassistant.components.caldav icalendar==6.3.1 @@ -2737,7 +2737,7 @@ wyoming==1.7.2 xiaomi-ble==1.6.0 # homeassistant.components.knx -xknx==3.14.0 +xknx==3.15.0 # homeassistant.components.knx xknxproject==3.8.2 diff --git a/tests/components/energy/test_data.py b/tests/components/energy/test_data.py index 69f0919dacd285..96f71d080f341a 100644 --- a/tests/components/energy/test_data.py +++ b/tests/components/energy/test_data.py @@ -160,15 +160,17 @@ async def test_grid_power_config_inverted_sets_stat_rate( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "power_config": { - "stat_rate_inverted": "sensor.grid_power", - }, - } - ], + "stat_energy_from": "sensor.grid_import", + "stat_energy_to": None, + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": None, + "entity_energy_price_export": None, + "number_energy_price_export": None, + "power_config": { + "stat_rate_inverted": "sensor.grid_power", + }, "cost_adjustment_day": 0, } ], @@ -177,7 +179,7 @@ async def test_grid_power_config_inverted_sets_stat_rate( assert manager.data is not None grid_source = manager.data["energy_sources"][0] - assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power_inverted" + assert grid_source["stat_rate"] == "sensor.grid_power_inverted" async def test_power_config_standard_uses_stat_rate_directly( @@ -319,25 +321,27 @@ async def test_flow_from_validation_multiple_prices() -> None: async def test_energy_sources_validation_multiple_grids() -> None: - """Test that multiple grid sources are rejected.""" - # Multiple grid sources should fail validation - with pytest.raises(vol.Invalid, match="You cannot have more than 1 grid source"): - ENERGY_SOURCE_SCHEMA( - [ - { - "type": "grid", - "flow_from": [], - "flow_to": [], - "cost_adjustment_day": 0, - }, - { - "type": "grid", - "flow_from": [], - "flow_to": [], - "cost_adjustment_day": 0, - }, - ] - ) + """Test that multiple grid sources are allowed (like batteries).""" + # Multiple grid sources should now pass validation + result = ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "stat_energy_from": "sensor.grid1_import", + "stat_energy_to": "sensor.grid1_export", + "cost_adjustment_day": 0, + }, + { + "type": "grid", + "stat_energy_from": "sensor.grid2_import", + "stat_energy_to": None, + "cost_adjustment_day": 0, + }, + ] + ) + assert len(result) == 2 + assert result[0]["stat_energy_from"] == "sensor.grid1_import" + assert result[1]["stat_energy_from"] == "sensor.grid2_import" async def test_power_config_validation_passes() -> None: @@ -371,15 +375,17 @@ async def test_grid_power_config_standard_stat_rate(hass: HomeAssistant) -> None "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "power_config": { - "stat_rate": "sensor.grid_power", - }, - } - ], + "stat_energy_from": "sensor.grid_import", + "stat_energy_to": None, + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": None, + "entity_energy_price_export": None, + "number_energy_price_export": None, + "power_config": { + "stat_rate": "sensor.grid_power", + }, "cost_adjustment_day": 0, } ], @@ -389,37 +395,44 @@ async def test_grid_power_config_standard_stat_rate(hass: HomeAssistant) -> None assert manager.data is not None grid_source = manager.data["energy_sources"][0] # stat_rate should be set directly from power_config.stat_rate - assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power" + assert grid_source["stat_rate"] == "sensor.grid_power" -async def test_flow_from_duplicate_stat_energy_from() -> None: - """Test that duplicate stat_energy_from values are rejected.""" - with pytest.raises( - vol.Invalid, match="Cannot specify sensor.energy more than once" - ): - ENERGY_SOURCE_SCHEMA( - [ - { - "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.energy", - "stat_cost": None, - "entity_energy_price": None, - "number_energy_price": 0.15, - }, - { - "stat_energy_from": "sensor.energy", # Duplicate - "stat_cost": None, - "entity_energy_price": None, - "number_energy_price": 0.20, - }, - ], - "flow_to": [], - "cost_adjustment_day": 0, - }, - ] - ) +async def test_grid_new_format_validates_correctly() -> None: + """Test that new unified grid format validates correctly.""" + # Valid grid source with import and export + result = ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "stat_energy_from": "sensor.energy_import", + "stat_energy_to": "sensor.energy_export", + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": 0.15, + "entity_energy_price_export": None, + "number_energy_price_export": 0.08, + "cost_adjustment_day": 0, + }, + ] + ) + assert len(result) == 1 + assert result[0]["stat_energy_from"] == "sensor.energy_import" + assert result[0]["stat_energy_to"] == "sensor.energy_export" + + # Valid grid source with import only (no export) + result = ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "stat_energy_from": "sensor.energy_import", + "stat_energy_to": None, + "cost_adjustment_day": 0, + }, + ] + ) + assert result[0]["stat_energy_to"] is None async def test_async_update_when_data_is_none(hass: HomeAssistant) -> None: @@ -453,7 +466,7 @@ async def test_async_update_when_data_is_none(hass: HomeAssistant) -> None: async def test_grid_power_without_power_config(hass: HomeAssistant) -> None: - """Test that grid power entry without power_config is preserved unchanged.""" + """Test that grid without power_config is preserved unchanged.""" manager = EnergyManager(hass) await manager.async_initialize() manager.data = manager.default_preferences() @@ -463,14 +476,16 @@ async def test_grid_power_without_power_config(hass: HomeAssistant) -> None: "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - # No power_config, just stat_rate directly - "stat_rate": "sensor.grid_power", - } - ], + "stat_energy_from": "sensor.grid_import", + "stat_energy_to": None, + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": None, + "entity_energy_price_export": None, + "number_energy_price_export": None, + # No power_config, just stat_rate directly + "stat_rate": "sensor.grid_power", "cost_adjustment_day": 0, } ], @@ -479,7 +494,362 @@ async def test_grid_power_without_power_config(hass: HomeAssistant) -> None: assert manager.data is not None grid_source = manager.data["energy_sources"][0] - # Power entry should be preserved unchanged - assert len(grid_source["power"]) == 1 - assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power" - assert "power_config" not in grid_source["power"][0] + # stat_rate should be preserved unchanged + assert grid_source["stat_rate"] == "sensor.grid_power" + assert "power_config" not in grid_source + + +async def test_grid_migration_single_import_export(hass: HomeAssistant) -> None: + """Test migration from legacy format with 1 import + 1 export creates 1 grid.""" + # Create legacy format data (v1.2) with flow_from/flow_to arrays + old_data = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_import", + "stat_cost": "sensor.grid_cost", + "entity_energy_price": None, + "number_energy_price": None, + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_export", + "stat_compensation": None, + "entity_energy_price": "sensor.sell_price", + "number_energy_price": None, + } + ], + "cost_adjustment_day": 0.5, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + # Save with old version (1.2) - migration will run to upgrade to 1.3 + old_store = storage.Store(hass, 1, "energy", minor_version=2) + await old_store.async_save(old_data) + + # Load with manager - should trigger migration + manager = EnergyManager(hass) + await manager.async_initialize() + + # Verify migration created unified grid source + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 1 + + grid = manager.data["energy_sources"][0] + assert grid["type"] == "grid" + assert grid["stat_energy_from"] == "sensor.grid_import" + assert grid["stat_energy_to"] == "sensor.grid_export" + assert grid["stat_cost"] == "sensor.grid_cost" + assert grid["stat_compensation"] is None + assert grid["entity_energy_price"] is None + assert grid["entity_energy_price_export"] == "sensor.sell_price" + assert grid["cost_adjustment_day"] == 0.5 + + # Should not have legacy fields + assert "flow_from" not in grid + assert "flow_to" not in grid + + +async def test_grid_migration_multiple_imports_exports_paired( + hass: HomeAssistant, +) -> None: + """Test migration with 2 imports + 2 exports creates 2 paired grids.""" + old_data = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_import_1", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.15, + }, + { + "stat_energy_from": "sensor.grid_import_2", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.20, + }, + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_export_1", + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": 0.08, + }, + { + "stat_energy_to": "sensor.grid_export_2", + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": 0.05, + }, + ], + "cost_adjustment_day": 0, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + old_store = storage.Store(hass, 1, "energy", minor_version=2) + await old_store.async_save(old_data) + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 2 + + # First grid: paired import_1 with export_1 + grid1 = manager.data["energy_sources"][0] + assert grid1["stat_energy_from"] == "sensor.grid_import_1" + assert grid1["stat_energy_to"] == "sensor.grid_export_1" + assert grid1["number_energy_price"] == 0.15 + assert grid1["number_energy_price_export"] == 0.08 + + # Second grid: paired import_2 with export_2 + grid2 = manager.data["energy_sources"][1] + assert grid2["stat_energy_from"] == "sensor.grid_import_2" + assert grid2["stat_energy_to"] == "sensor.grid_export_2" + assert grid2["number_energy_price"] == 0.20 + assert grid2["number_energy_price_export"] == 0.05 + + +async def test_grid_migration_more_imports_than_exports(hass: HomeAssistant) -> None: + """Test migration with 3 imports + 1 export creates 3 grids (first has export).""" + old_data = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + {"stat_energy_from": "sensor.import_1"}, + {"stat_energy_from": "sensor.import_2"}, + {"stat_energy_from": "sensor.import_3"}, + ], + "flow_to": [ + {"stat_energy_to": "sensor.export_1"}, + ], + "cost_adjustment_day": 0, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + old_store = storage.Store(hass, 1, "energy", minor_version=2) + await old_store.async_save(old_data) + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 3 + + # First grid: has both import and export + grid1 = manager.data["energy_sources"][0] + assert grid1["stat_energy_from"] == "sensor.import_1" + assert grid1["stat_energy_to"] == "sensor.export_1" + + # Second and third grids: import only + grid2 = manager.data["energy_sources"][1] + assert grid2["stat_energy_from"] == "sensor.import_2" + assert grid2["stat_energy_to"] is None + + grid3 = manager.data["energy_sources"][2] + assert grid3["stat_energy_from"] == "sensor.import_3" + assert grid3["stat_energy_to"] is None + + +async def test_grid_migration_with_power(hass: HomeAssistant) -> None: + """Test migration preserves power config and stat_rate from first grid. + + Note: Migration preserves the original stat_rate value from the legacy power array. + The stat_rate regeneration from power_config only happens during async_update() + for new data submissions, not during storage migration. + """ + old_data = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + {"stat_energy_from": "sensor.grid_import"}, + ], + "flow_to": [ + {"stat_energy_to": "sensor.grid_export"}, + ], + "power": [ + { + "stat_rate": "sensor.grid_power", + "power_config": {"stat_rate_inverted": "sensor.grid_power"}, + } + ], + "cost_adjustment_day": 0, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + old_store = storage.Store(hass, 1, "energy", minor_version=2) + await old_store.async_save(old_data) + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + grid = manager.data["energy_sources"][0] + + # Verify power_config is preserved + assert grid["power_config"] == {"stat_rate_inverted": "sensor.grid_power"} + + # Migration preserves the original stat_rate value from the legacy power array + # (stat_rate regeneration from power_config only happens in async_update) + assert grid["stat_rate"] == "sensor.grid_power" + + +async def test_grid_migration_import_only(hass: HomeAssistant) -> None: + """Test migration with imports but no exports creates import-only grids.""" + old_data = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + {"stat_energy_from": "sensor.grid_import"}, + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + old_store = storage.Store(hass, 1, "energy", minor_version=2) + await old_store.async_save(old_data) + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 1 + + grid = manager.data["energy_sources"][0] + assert grid["stat_energy_from"] == "sensor.grid_import" + assert grid["stat_energy_to"] is None + + +async def test_grid_migration_power_only(hass: HomeAssistant) -> None: + """Test migration with only power configured (no import/export meters).""" + old_data = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + {"stat_rate": "sensor.grid_power"}, + ], + "cost_adjustment_day": 0.5, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + old_store = storage.Store(hass, 1, "energy", minor_version=2) + await old_store.async_save(old_data) + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 1 + + grid = manager.data["energy_sources"][0] + assert grid["type"] == "grid" + # No import or export meters + assert grid["stat_energy_from"] is None + assert grid["stat_energy_to"] is None + # Power is preserved + assert grid["stat_rate"] == "sensor.grid_power" + assert grid["cost_adjustment_day"] == 0.5 + + +async def test_grid_new_format_no_migration_needed(hass: HomeAssistant) -> None: + """Test that new format data doesn't get migrated.""" + new_data = { + "energy_sources": [ + { + "type": "grid", + "stat_energy_from": "sensor.grid_import", + "stat_energy_to": "sensor.grid_export", + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": 0.15, + "entity_energy_price_export": None, + "number_energy_price_export": 0.08, + "cost_adjustment_day": 0, + } + ], + "device_consumption": [], + "device_consumption_water": [], + } + + # Save with current version (1.3) + old_store = storage.Store(hass, 1, "energy", minor_version=3) + await old_store.async_save(new_data) + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 1 + grid = manager.data["energy_sources"][0] + assert grid["stat_energy_from"] == "sensor.grid_import" + assert grid["stat_energy_to"] == "sensor.grid_export" + + +async def test_grid_validation_single_import_price() -> None: + """Test that grid validation rejects both entity and number import price.""" + with pytest.raises( + vol.Invalid, match="Define either an entity or a fixed number for import price" + ): + ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "stat_energy_from": "sensor.grid_import", + "entity_energy_price": "sensor.price", + "number_energy_price": 0.15, + "cost_adjustment_day": 0, + } + ] + ) + + +async def test_grid_validation_single_export_price() -> None: + """Test that grid validation rejects both entity and number export price.""" + with pytest.raises( + vol.Invalid, match="Define either an entity or a fixed number for export price" + ): + ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "stat_energy_from": "sensor.grid_import", + "stat_energy_to": "sensor.grid_export", + "entity_energy_price_export": "sensor.sell_price", + "number_energy_price_export": 0.08, + "cost_adjustment_day": 0, + } + ] + ) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 434bf3c07f8746..0d7a269b0e02b1 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -149,13 +149,35 @@ async def test_cost_sensor_attributes( ("price_entity", "fixed_price"), [("sensor.energy_price", None), (None, 1)] ) @pytest.mark.parametrize( - ("usage_sensor_entity_id", "cost_sensor_entity_id", "flow_type"), + ( + "usage_sensor_entity_id", + "cost_sensor_entity_id", + "flow_type", + "energy_source_data", + "price_update_key", + ), [ - ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_consumption", + "sensor.energy_consumption_cost", + "flow_from", + { + "type": "grid", + "stat_energy_from": "sensor.energy_consumption", + "cost_adjustment_day": 0, + }, + "number_energy_price", + ), ( "sensor.energy_production", "sensor.energy_production_compensation", "flow_to", + { + "type": "grid", + "stat_energy_to": "sensor.energy_production", + "cost_adjustment_day": 0, + }, + "number_energy_price_export", ), ], ) @@ -173,6 +195,8 @@ async def test_cost_sensor_price_entity_total_increasing( usage_sensor_entity_id, cost_sensor_entity_id, flow_type, + energy_source_data: dict[str, Any], + price_update_key: str, ) -> None: """Test energy cost price from total_increasing type sensor entity.""" @@ -188,32 +212,15 @@ def _compile_statistics(_): } energy_data = data.EnergyManager.default_preferences() - energy_data["energy_sources"].append( - { - "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.energy_consumption", - "stat_cost": None, - "entity_energy_price": price_entity, - "number_energy_price": fixed_price, - } - ] - if flow_type == "flow_from" - else [], - "flow_to": [ - { - "stat_energy_to": "sensor.energy_production", - "stat_compensation": None, - "entity_energy_price": price_entity, - "number_energy_price": fixed_price, - } - ] - if flow_type == "flow_to" - else [], - "cost_adjustment_day": 0, - } - ) + # Build energy source from test parameter data, adding price fields + energy_source = copy.deepcopy(energy_source_data) + if flow_type == "flow_from": + energy_source["entity_energy_price"] = price_entity + energy_source["number_energy_price"] = fixed_price + else: + energy_source["entity_energy_price_export"] = price_entity + energy_source["number_energy_price_export"] = fixed_price + energy_data["energy_sources"].append(energy_source) hass_storage[data.STORAGE_KEY] = { "version": 1, @@ -282,7 +289,7 @@ def _compile_statistics(_): await hass.async_block_till_done() else: energy_data = copy.deepcopy(energy_data) - energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + energy_data["energy_sources"][0][price_update_key] = 2 client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) msg = await client.receive_json() @@ -360,13 +367,35 @@ def _compile_statistics(_): ("price_entity", "fixed_price"), [("sensor.energy_price", None), (None, 1)] ) @pytest.mark.parametrize( - ("usage_sensor_entity_id", "cost_sensor_entity_id", "flow_type"), + ( + "usage_sensor_entity_id", + "cost_sensor_entity_id", + "flow_type", + "energy_source_data", + "price_update_key", + ), [ - ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_consumption", + "sensor.energy_consumption_cost", + "flow_from", + { + "type": "grid", + "stat_energy_from": "sensor.energy_consumption", + "cost_adjustment_day": 0, + }, + "number_energy_price", + ), ( "sensor.energy_production", "sensor.energy_production_compensation", "flow_to", + { + "type": "grid", + "stat_energy_to": "sensor.energy_production", + "cost_adjustment_day": 0, + }, + "number_energy_price_export", ), ], ) @@ -385,6 +414,8 @@ async def test_cost_sensor_price_entity_total( usage_sensor_entity_id, cost_sensor_entity_id, flow_type, + energy_source_data: dict[str, Any], + price_update_key: str, energy_state_class, ) -> None: """Test energy cost price from total type sensor entity.""" @@ -401,32 +432,15 @@ def _compile_statistics(_): } energy_data = data.EnergyManager.default_preferences() - energy_data["energy_sources"].append( - { - "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.energy_consumption", - "stat_cost": None, - "entity_energy_price": price_entity, - "number_energy_price": fixed_price, - } - ] - if flow_type == "flow_from" - else [], - "flow_to": [ - { - "stat_energy_to": "sensor.energy_production", - "stat_compensation": None, - "entity_energy_price": price_entity, - "number_energy_price": fixed_price, - } - ] - if flow_type == "flow_to" - else [], - "cost_adjustment_day": 0, - } - ) + # Build energy source from test parameter data, adding price fields + energy_source = copy.deepcopy(energy_source_data) + if flow_type == "flow_from": + energy_source["entity_energy_price"] = price_entity + energy_source["number_energy_price"] = fixed_price + else: + energy_source["entity_energy_price_export"] = price_entity + energy_source["number_energy_price_export"] = fixed_price + energy_data["energy_sources"].append(energy_source) hass_storage[data.STORAGE_KEY] = { "version": 1, @@ -496,7 +510,7 @@ def _compile_statistics(_): await hass.async_block_till_done() else: energy_data = copy.deepcopy(energy_data) - energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + energy_data["energy_sources"][0][price_update_key] = 2 client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) msg = await client.receive_json() @@ -575,13 +589,35 @@ def _compile_statistics(_): ("price_entity", "fixed_price"), [("sensor.energy_price", None), (None, 1)] ) @pytest.mark.parametrize( - ("usage_sensor_entity_id", "cost_sensor_entity_id", "flow_type"), + ( + "usage_sensor_entity_id", + "cost_sensor_entity_id", + "flow_type", + "energy_source_data", + "price_update_key", + ), [ - ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_consumption", + "sensor.energy_consumption_cost", + "flow_from", + { + "type": "grid", + "stat_energy_from": "sensor.energy_consumption", + "cost_adjustment_day": 0, + }, + "number_energy_price", + ), ( "sensor.energy_production", "sensor.energy_production_compensation", "flow_to", + { + "type": "grid", + "stat_energy_to": "sensor.energy_production", + "cost_adjustment_day": 0, + }, + "number_energy_price_export", ), ], ) @@ -600,6 +636,8 @@ async def test_cost_sensor_price_entity_total_no_reset( usage_sensor_entity_id, cost_sensor_entity_id, flow_type, + energy_source_data: dict[str, Any], + price_update_key: str, energy_state_class, ) -> None: """Test energy cost price from total type sensor entity with no last_reset.""" @@ -616,32 +654,15 @@ def _compile_statistics(_): } energy_data = data.EnergyManager.default_preferences() - energy_data["energy_sources"].append( - { - "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.energy_consumption", - "stat_cost": None, - "entity_energy_price": price_entity, - "number_energy_price": fixed_price, - } - ] - if flow_type == "flow_from" - else [], - "flow_to": [ - { - "stat_energy_to": "sensor.energy_production", - "stat_compensation": None, - "entity_energy_price": price_entity, - "number_energy_price": fixed_price, - } - ] - if flow_type == "flow_to" - else [], - "cost_adjustment_day": 0, - } - ) + # Build energy source from test parameter data, adding price fields + energy_source = copy.deepcopy(energy_source_data) + if flow_type == "flow_from": + energy_source["entity_energy_price"] = price_entity + energy_source["number_energy_price"] = fixed_price + else: + energy_source["entity_energy_price_export"] = price_entity + energy_source["number_energy_price_export"] = fixed_price + energy_data["energy_sources"].append(energy_source) hass_storage[data.STORAGE_KEY] = { "version": 1, @@ -710,7 +731,7 @@ def _compile_statistics(_): await hass.async_block_till_done() else: energy_data = copy.deepcopy(energy_data) - energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + energy_data["energy_sources"][0][price_update_key] = 2 client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) msg = await client.receive_json() @@ -1490,24 +1511,12 @@ async def test_power_sensor_grid_combined( "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.grid_energy_import", - } - ], - "flow_to": [ - { - "stat_energy_to": "sensor.grid_energy_export", - } - ], - "power": [ - { - "power_config": { - "stat_rate_from": "sensor.grid_import", - "stat_rate_to": "sensor.grid_export", - } - } - ], + "stat_energy_from": "sensor.grid_energy_import", + "stat_energy_to": "sensor.grid_energy_export", + "power_config": { + "stat_rate_from": "sensor.grid_import", + "stat_rate_to": "sensor.grid_export", + }, "cost_adjustment_day": 0, } ], diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index a0ce72f4c8e94d..e35c8405fe4414 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -371,19 +371,11 @@ async def test_validation_grid( "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.grid_consumption_1", - "stat_cost": "sensor.grid_cost_1", - } - ], - "flow_to": [ - { - "stat_energy_to": "sensor.grid_production_1", - "stat_compensation": "sensor.grid_compensation_1", - } - ], - "power": [], + "stat_energy_from": "sensor.grid_consumption_1", + "stat_energy_to": "sensor.grid_production_1", + "stat_cost": "sensor.grid_cost_1", + "stat_compensation": "sensor.grid_compensation_1", + "cost_adjustment_day": 0.0, } ] } @@ -464,19 +456,11 @@ async def test_validation_grid_external_cost_compensation( "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.grid_consumption_1", - "stat_cost": "external:grid_cost_1", - } - ], - "flow_to": [ - { - "stat_energy_to": "sensor.grid_production_1", - "stat_compensation": "external:grid_compensation_1", - } - ], - "power": [], + "stat_energy_from": "sensor.grid_consumption_1", + "stat_energy_to": "sensor.grid_production_1", + "stat_cost": "external:grid_cost_1", + "stat_compensation": "external:grid_compensation_1", + "cost_adjustment_day": 0.0, } ] } @@ -559,20 +543,11 @@ async def test_validation_grid_price_not_exist( "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.grid_consumption_1", - "entity_energy_price": "sensor.grid_price_1", - "number_energy_price": None, - } - ], - "flow_to": [ - { - "stat_energy_to": "sensor.grid_production_1", - "entity_energy_price": None, - "number_energy_price": 0.10, - } - ], + "stat_energy_from": "sensor.grid_consumption_1", + "stat_energy_to": "sensor.grid_production_1", + "entity_energy_price": "sensor.grid_price_1", + "number_energy_price_export": 0.10, + "cost_adjustment_day": 0.0, } ] } @@ -710,15 +685,9 @@ async def test_validation_grid_price_errors( "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.grid_consumption_1", - "entity_energy_price": "sensor.grid_price_1", - "number_energy_price": None, - } - ], - "flow_to": [], - "power": [], + "stat_energy_from": "sensor.grid_consumption_1", + "entity_energy_price": "sensor.grid_price_1", + "cost_adjustment_day": 0.0, } ] } diff --git a/tests/components/energy/test_validate_power.py b/tests/components/energy/test_validate_power.py index a3e7654397cd52..02fdf2e981b19f 100644 --- a/tests/components/energy/test_validate_power.py +++ b/tests/components/energy/test_validate_power.py @@ -27,13 +27,7 @@ async def test_validation_grid_power_valid( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.grid_power", - } - ], + "stat_rate": "sensor.grid_power", "cost_adjustment_day": 0.0, } ] @@ -66,13 +60,7 @@ async def test_validation_grid_power_wrong_unit( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.grid_power", - } - ], + "stat_rate": "sensor.grid_power", "cost_adjustment_day": 0.0, } ] @@ -113,13 +101,7 @@ async def test_validation_grid_power_wrong_state_class( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.grid_power", - } - ], + "stat_rate": "sensor.grid_power", "cost_adjustment_day": 0.0, } ] @@ -160,13 +142,7 @@ async def test_validation_grid_power_entity_missing( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.missing_power", - } - ], + "stat_rate": "sensor.missing_power", "cost_adjustment_day": 0.0, } ] @@ -203,13 +179,7 @@ async def test_validation_grid_power_entity_unavailable( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.unavailable_power", - } - ], + "stat_rate": "sensor.unavailable_power", "cost_adjustment_day": 0.0, } ] @@ -242,13 +212,7 @@ async def test_validation_grid_power_entity_non_numeric( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.non_numeric_power", - } - ], + "stat_rate": "sensor.non_numeric_power", "cost_adjustment_day": 0.0, } ] @@ -289,13 +253,7 @@ async def test_validation_grid_power_wrong_device_class( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.wrong_device_class_power", - } - ], + "stat_rate": "sensor.wrong_device_class_power", "cost_adjustment_day": 0.0, } ] @@ -333,23 +291,20 @@ async def test_validation_grid_power_different_units( hass: HomeAssistant, mock_energy_manager, mock_get_metadata ) -> None: """Test validating grid with power sensors using different valid units.""" + # With unified format, each grid has one power sensor, so we use two grids await mock_energy_manager.async_update( { "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.power_watt", - }, - { - "stat_rate": "sensor.power_milliwatt", - }, - ], + "stat_rate": "sensor.power_watt", "cost_adjustment_day": 0.0, - } + }, + { + "type": "grid", + "stat_rate": "sensor.power_milliwatt", + "cost_adjustment_day": 0.0, + }, ] } ) @@ -374,7 +329,7 @@ async def test_validation_grid_power_different_units( result = await validate.async_validate(hass) assert result.as_dict() == { - "energy_sources": [[]], + "energy_sources": [[], []], "device_consumption": [], "device_consumption_water": [], } @@ -391,13 +346,7 @@ async def test_validation_grid_power_external_statistics( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "external:power_stat", - } - ], + "stat_rate": "external:power_stat", "cost_adjustment_day": 0.0, } ] @@ -431,13 +380,7 @@ async def test_validation_grid_power_recorder_untracked( "energy_sources": [ { "type": "grid", - "flow_from": [], - "flow_to": [], - "power": [ - { - "stat_rate": "sensor.untracked_power", - } - ], + "stat_rate": "sensor.untracked_power", "cost_adjustment_day": 0.0, } ] diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index b683521840202c..636dac5f0774a5 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -107,41 +107,31 @@ async def test_save_preferences( new_prefs = { "energy_sources": [ + # Grid 1: heat_pump_meter paired with return_to_grid_peak + power { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.heat_pump_meter", - "stat_cost": "heat_pump_kwh_cost", - "entity_energy_price": None, - "number_energy_price": None, - }, - { - "stat_energy_from": "sensor.heat_pump_meter_2", - "stat_cost": None, - "entity_energy_price": None, - "number_energy_price": 0.20, - }, - ], - "flow_to": [ - { - "stat_energy_to": "sensor.return_to_grid_peak", - "stat_compensation": None, - "entity_energy_price": None, - "number_energy_price": None, - }, - { - "stat_energy_to": "sensor.return_to_grid_offpeak", - "stat_compensation": None, - "entity_energy_price": None, - "number_energy_price": 0.20, - }, - ], - "power": [ - { - "stat_rate": "sensor.grid_power", - } - ], + "stat_energy_from": "sensor.heat_pump_meter", + "stat_energy_to": "sensor.return_to_grid_peak", + "stat_cost": "heat_pump_kwh_cost", + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": None, + "entity_energy_price_export": None, + "number_energy_price_export": None, + "stat_rate": "sensor.grid_power", + "cost_adjustment_day": 1.2, + }, + # Grid 2: heat_pump_meter_2 paired with return_to_grid_offpeak + { + "type": "grid", + "stat_energy_from": "sensor.heat_pump_meter_2", + "stat_energy_to": "sensor.return_to_grid_offpeak", + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": 0.20, + "entity_energy_price_export": None, + "number_energy_price_export": 0.20, "cost_adjustment_day": 1.2, }, { @@ -206,20 +196,19 @@ async def test_save_preferences( "solar_forecast_domains": ["some_domain"], } - # Prefs with limited options + # Prefs with limited options (defaults will be applied by schema) new_prefs_2 = { "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.heat_pump_meter", - "stat_cost": None, - "entity_energy_price": None, - "number_energy_price": None, - } - ], - "flow_to": [], + "stat_energy_from": "sensor.heat_pump_meter", + "stat_energy_to": None, + "stat_cost": None, + "stat_compensation": None, + "entity_energy_price": None, + "number_energy_price": None, + "entity_energy_price_export": None, + "number_energy_price_export": None, "cost_adjustment_day": 1.2, }, { @@ -242,9 +231,10 @@ async def test_save_preferences( async def test_handle_duplicate_from_stat( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test we handle duplicate from stats.""" + """Test we handle duplicate from stats across multiple grid sources.""" client = await hass_ws_client(hass) + # Try to create two grids with the same import meter await client.send_json( { "id": 5, @@ -252,22 +242,12 @@ async def test_handle_duplicate_from_stat( "energy_sources": [ { "type": "grid", - "flow_from": [ - { - "stat_energy_from": "sensor.heat_pump_meter", - "stat_cost": None, - "entity_energy_price": None, - "number_energy_price": None, - }, - { - "stat_energy_from": "sensor.heat_pump_meter", - "stat_cost": None, - "entity_energy_price": None, - "number_energy_price": None, - }, - ], - "flow_to": [], - "power": [], + "stat_energy_from": "sensor.heat_pump_meter", + "cost_adjustment_day": 0, + }, + { + "type": "grid", + "stat_energy_from": "sensor.heat_pump_meter", "cost_adjustment_day": 0, }, ], diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 840959bb6c5400..53ac8563166326 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -18,6 +18,7 @@ MOCK_TIMESTAMP = "2023-07-02T14:51:24.045162-07:00" MOCK_TELEGRAMS = [ { + "data_secure": None, # None since CEMIHandler is mocked away and doesn't set it to False "destination": "1/3/4", "destination_name": "", "direction": "Incoming", @@ -33,6 +34,7 @@ "value": None, }, { + "data_secure": None, "destination": "2/2/2", "destination_name": "", "direction": "Outgoing", diff --git a/tests/components/knx/test_time_server.py b/tests/components/knx/test_time_server.py index 4db361c1dd3960..7898c517716951 100644 --- a/tests/components/knx/test_time_server.py +++ b/tests/components/knx/test_time_server.py @@ -111,9 +111,9 @@ async def test_time_server_load_from_config_store( {}, config_store_fixture="config_store_time_server.json" ) # Verify all three formats are written on startup - await knx.assert_write("1/1/1", RAW_TIME) - await knx.assert_write("2/2/2", RAW_DATE) - await knx.assert_write("3/3/3", RAW_DATETIME) + await knx.assert_write("1/1/1", RAW_TIME, ignore_order=True) + await knx.assert_write("2/2/2", RAW_DATE, ignore_order=True) + await knx.assert_write("3/3/3", RAW_DATETIME, ignore_order=True) client = await hass_ws_client(hass) # Verify configuration was loaded