diff --git a/_sample.case.txt b/_sample.case.txt new file mode 100644 index 0000000..d91c09b --- /dev/null +++ b/_sample.case.txt @@ -0,0 +1,305 @@ +## binary_sensor.stromligning_tomorrow_available_vat +available_at: 13:22:18 +forecast_data: false +prices: +- end: '2026-03-15T00:00:00+01:00' + +icon: mdi:calendar-end +friendly_name: Strømligning Prices for tomorrow incl. VAT + +## sensor.stromligning_current_price_vat +state_class: total +prices: +- price: 1.659293 + start: '2026-03-14T00:00:00+01:00' + end: '2026-03-14T00:15:00+01:00' +- price: 1.625574 + start: '2026-03-14T00:15:00+01:00' + end: '2026-03-14T00:30:00+01:00' +- price: 1.487806 + start: '2026-03-14T00:30:00+01:00' + end: '2026-03-14T00:45:00+01:00' +- price: 1.457824 + start: '2026-03-14T00:45:00+01:00' + end: '2026-03-14T01:00:00+01:00' +- price: 1.552627 + start: '2026-03-14T01:00:00+01:00' + end: '2026-03-14T01:15:00+01:00' +- price: 1.527222 + start: '2026-03-14T01:15:00+01:00' + end: '2026-03-14T01:30:00+01:00' +- price: 1.514706 + start: '2026-03-14T01:30:00+01:00' + end: '2026-03-14T01:45:00+01:00' +- price: 1.491262 + start: '2026-03-14T01:45:00+01:00' + end: '2026-03-14T02:00:00+01:00' +- price: 1.495185 + start: '2026-03-14T02:00:00+01:00' + end: '2026-03-14T02:15:00+01:00' +- price: 1.475757 + start: '2026-03-14T02:15:00+01:00' + end: '2026-03-14T02:30:00+01:00' +- price: 1.468285 + start: '2026-03-14T02:30:00+01:00' + end: '2026-03-14T02:45:00+01:00' +- price: 1.457076 + start: '2026-03-14T02:45:00+01:00' + end: '2026-03-14T03:00:00+01:00' +- price: 1.430737 + start: '2026-03-14T03:00:00+01:00' + end: '2026-03-14T03:15:00+01:00' +- price: 1.453714 + start: '2026-03-14T03:15:00+01:00' + end: '2026-03-14T03:30:00+01:00' +- price: 1.463054 + start: '2026-03-14T03:30:00+01:00' + end: '2026-03-14T03:45:00+01:00' +- price: 1.469405 + start: '2026-03-14T03:45:00+01:00' + end: '2026-03-14T04:00:00+01:00' +- price: 1.46679 + start: '2026-03-14T04:00:00+01:00' + end: '2026-03-14T04:15:00+01:00' +- price: 1.466697 + start: '2026-03-14T04:15:00+01:00' + end: '2026-03-14T04:30:00+01:00' +- price: 1.495745 + start: '2026-03-14T04:30:00+01:00' + end: '2026-03-14T04:45:00+01:00' +- price: 1.509008 + start: '2026-03-14T04:45:00+01:00' + end: '2026-03-14T05:00:00+01:00' +- price: 1.478839 + start: '2026-03-14T05:00:00+01:00' + end: '2026-03-14T05:15:00+01:00' +- price: 1.505646 + start: '2026-03-14T05:15:00+01:00' + end: '2026-03-14T05:30:00+01:00' +- price: 1.53376 + start: '2026-03-14T05:30:00+01:00' + end: '2026-03-14T05:45:00+01:00' +- price: 1.567478 + start: '2026-03-14T05:45:00+01:00' + end: '2026-03-14T06:00:00+01:00' +- price: 1.739403 + start: '2026-03-14T06:00:00+01:00' + end: '2026-03-14T06:15:00+01:00' +- price: 1.787132 + start: '2026-03-14T06:15:00+01:00' + end: '2026-03-14T06:30:00+01:00' +- price: 1.807026 + start: '2026-03-14T06:30:00+01:00' + end: '2026-03-14T06:45:00+01:00' +- price: 1.820103 + start: '2026-03-14T06:45:00+01:00' + end: '2026-03-14T07:00:00+01:00' +- price: 1.864749 + start: '2026-03-14T07:00:00+01:00' + end: '2026-03-14T07:15:00+01:00' +- price: 1.82935 + start: '2026-03-14T07:15:00+01:00' + end: '2026-03-14T07:30:00+01:00' +- price: 1.827855 + start: '2026-03-14T07:30:00+01:00' + end: '2026-03-14T07:45:00+01:00' +- price: 1.825613 + start: '2026-03-14T07:45:00+01:00' + end: '2026-03-14T08:00:00+01:00' +- price: 1.886232 + start: '2026-03-14T08:00:00+01:00' + end: '2026-03-14T08:15:00+01:00' +- price: 1.885765 + start: '2026-03-14T08:15:00+01:00' + end: '2026-03-14T08:30:00+01:00' +- price: 1.886138 + start: '2026-03-14T08:30:00+01:00' + end: '2026-03-14T08:45:00+01:00' +- price: 1.882496 + start: '2026-03-14T08:45:00+01:00' + end: '2026-03-14T09:00:00+01:00' +- price: 1.868018 + start: '2026-03-14T09:00:00+01:00' + end: '2026-03-14T09:15:00+01:00' +- price: 1.867831 + start: '2026-03-14T09:15:00+01:00' + end: '2026-03-14T09:30:00+01:00' +- price: 1.834954 + start: '2026-03-14T09:30:00+01:00' + end: '2026-03-14T09:45:00+01:00' +- price: 1.755655 + start: '2026-03-14T09:45:00+01:00' + end: '2026-03-14T10:00:00+01:00' +- price: 1.795351 + start: '2026-03-14T10:00:00+01:00' + end: '2026-03-14T10:15:00+01:00' +- price: 1.7621 + start: '2026-03-14T10:15:00+01:00' + end: '2026-03-14T10:30:00+01:00' +- price: 1.735667 + start: '2026-03-14T10:30:00+01:00' + end: '2026-03-14T10:45:00+01:00' +- price: 1.711476 + start: '2026-03-14T10:45:00+01:00' + end: '2026-03-14T11:00:00+01:00' +- price: 1.715959 + start: '2026-03-14T11:00:00+01:00' + end: '2026-03-14T11:15:00+01:00' +- price: 1.696158 + start: '2026-03-14T11:15:00+01:00' + end: '2026-03-14T11:30:00+01:00' +- price: 1.715492 + start: '2026-03-14T11:30:00+01:00' + end: '2026-03-14T11:45:00+01:00' +- price: 1.727914 + start: '2026-03-14T11:45:00+01:00' + end: '2026-03-14T12:00:00+01:00' +- price: 1.723525 + start: '2026-03-14T12:00:00+01:00' + end: '2026-03-14T12:15:00+01:00' +- price: 1.718948 + start: '2026-03-14T12:15:00+01:00' + end: '2026-03-14T12:30:00+01:00' +- price: 1.723338 + start: '2026-03-14T12:30:00+01:00' + end: '2026-03-14T12:45:00+01:00' +- price: 1.714651 + start: '2026-03-14T12:45:00+01:00' + end: '2026-03-14T13:00:00+01:00' +- price: 1.728848 + start: '2026-03-14T13:00:00+01:00' + end: '2026-03-14T13:15:00+01:00' +- price: 1.728288 + start: '2026-03-14T13:15:00+01:00' + end: '2026-03-14T13:30:00+01:00' +- price: 1.696438 + start: '2026-03-14T13:30:00+01:00' + end: '2026-03-14T13:45:00+01:00' +- price: 1.698306 + start: '2026-03-14T13:45:00+01:00' + end: '2026-03-14T14:00:00+01:00' +- price: 1.716239 + start: '2026-03-14T14:00:00+01:00' + end: '2026-03-14T14:15:00+01:00' +- price: 1.72642 + start: '2026-03-14T14:15:00+01:00' + end: '2026-03-14T14:30:00+01:00' +- price: 1.731277 + start: '2026-03-14T14:30:00+01:00' + end: '2026-03-14T14:45:00+01:00' +- price: 1.761913 + start: '2026-03-14T14:45:00+01:00' + end: '2026-03-14T15:00:00+01:00' +- price: 1.690927 + start: '2026-03-14T15:00:00+01:00' + end: '2026-03-14T15:15:00+01:00' +- price: 1.811603 + start: '2026-03-14T15:15:00+01:00' + end: '2026-03-14T15:30:00+01:00' +- price: 1.870353 + start: '2026-03-14T15:30:00+01:00' + end: '2026-03-14T15:45:00+01:00' +- price: 1.882122 + start: '2026-03-14T15:45:00+01:00' + end: '2026-03-14T16:00:00+01:00' +- price: 1.868392 + start: '2026-03-14T16:00:00+01:00' + end: '2026-03-14T16:15:00+01:00' +- price: 1.879974 + start: '2026-03-14T16:15:00+01:00' + end: '2026-03-14T16:30:00+01:00' +- price: 1.979447 + start: '2026-03-14T16:30:00+01:00' + end: '2026-03-14T16:45:00+01:00' +- price: 2.116842 + start: '2026-03-14T16:45:00+01:00' + end: '2026-03-14T17:00:00+01:00' +- price: 2.56434 + start: '2026-03-14T17:00:00+01:00' + end: '2026-03-14T17:15:00+01:00' +- price: 2.711075 + start: '2026-03-14T17:15:00+01:00' + end: '2026-03-14T17:30:00+01:00' +- price: 2.809802 + start: '2026-03-14T17:30:00+01:00' + end: '2026-03-14T17:45:00+01:00' +- price: 3.011925 + start: '2026-03-14T17:45:00+01:00' + end: '2026-03-14T18:00:00+01:00' +- price: 2.786825 + start: '2026-03-14T18:00:00+01:00' + end: '2026-03-14T18:15:00+01:00' +- price: 2.865376 + start: '2026-03-14T18:15:00+01:00' + end: '2026-03-14T18:30:00+01:00' +- price: 3.090943 + start: '2026-03-14T18:30:00+01:00' + end: '2026-03-14T18:45:00+01:00' +- price: 3.192378 + start: '2026-03-14T18:45:00+01:00' + end: '2026-03-14T19:00:00+01:00' +- price: 3.192939 + start: '2026-03-14T19:00:00+01:00' + end: '2026-03-14T19:15:00+01:00' +- price: 3.144836 + start: '2026-03-14T19:15:00+01:00' + end: '2026-03-14T19:30:00+01:00' +- price: 3.025001 + start: '2026-03-14T19:30:00+01:00' + end: '2026-03-14T19:45:00+01:00' +- price: 2.898908 + start: '2026-03-14T19:45:00+01:00' + end: '2026-03-14T20:00:00+01:00' +- price: 3.126436 + start: '2026-03-14T20:00:00+01:00' + end: '2026-03-14T20:15:00+01:00' +- price: 2.938043 + start: '2026-03-14T20:15:00+01:00' + end: '2026-03-14T20:30:00+01:00' +- price: 2.786544 + start: '2026-03-14T20:30:00+01:00' + end: '2026-03-14T20:45:00+01:00' +- price: 2.679785 + start: '2026-03-14T20:45:00+01:00' + end: '2026-03-14T21:00:00+01:00' +- price: 2.05445 + start: '2026-03-14T21:00:00+01:00' + end: '2026-03-14T21:15:00+01:00' +- price: 1.994112 + start: '2026-03-14T21:15:00+01:00' + end: '2026-03-14T21:30:00+01:00' +- price: 2.058186 + start: '2026-03-14T21:30:00+01:00' + end: '2026-03-14T21:45:00+01:00' +- price: 2.015874 + start: '2026-03-14T21:45:00+01:00' + end: '2026-03-14T22:00:00+01:00' +- price: 2.05006 + start: '2026-03-14T22:00:00+01:00' + end: '2026-03-14T22:15:00+01:00' +- price: 1.989068 + start: '2026-03-14T22:15:00+01:00' + end: '2026-03-14T22:30:00+01:00' +- price: 2.052675 + start: '2026-03-14T22:30:00+01:00' + end: '2026-03-14T22:45:00+01:00' +- price: 1.948158 + start: '2026-03-14T22:45:00+01:00' + end: '2026-03-14T23:00:00+01:00' +- price: 2.050807 + start: '2026-03-14T23:00:00+01:00' + end: '2026-03-14T23:15:00+01:00' +- price: 1.950119 + start: '2026-03-14T23:15:00+01:00' + end: '2026-03-14T23:30:00+01:00' +- price: 1.936295 + start: '2026-03-14T23:30:00+01:00' + end: '2026-03-14T23:45:00+01:00' +- price: 1.834113 + start: '2026-03-14T23:45:00+01:00' + end: '2026-03-15T00:00:00+01:00' + +unit_of_measurement: kr/kWh +device_class: monetary +icon: mdi:flash +friendly_name: Strømligning Current incl. VAT \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index d885719..4177697 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,12 +30,12 @@ WattPlan acquires four planner input series: - **Usage** - **PV** -Each source group is configured with one provider mode at a time. Supported modes depend on the source: +Each source group stores one or more provider definitions. Supported provider modes depend on the source: - Price and export price: entity adapter, service adapter, or template - Usage: built-in history-based forecast, entity adapter, service adapter, or template - PV: Home Assistant Energy solar provider, entity adapter, service adapter, or template -Entity-adapter mode can optionally merge multiple same-shaped entities into one combined payload before normalization. Aside from that case, each source resolves through one configured provider path. +Every provider first resolves into timestamp/value points. The source pipeline then concatenates all provider output for that source before normalization, slot aggregation, repair, and fixup run once on the merged stream. Runtime planning can tolerate one provider failing or producing no usable points when another provider still covers the source. The acquisition pipeline for each source is: 1. Select the configured provider mode and fetch raw payload or direct slot values. diff --git a/src/custom_components/wattplan/config_flow.py b/src/custom_components/wattplan/config_flow.py index f0a5db9..bdd290e 100644 --- a/src/custom_components/wattplan/config_flow.py +++ b/src/custom_components/wattplan/config_flow.py @@ -63,6 +63,7 @@ CONF_ON_OFF_SOURCE, CONF_OPTIONS_COUNT, CONF_PLANNING_ENABLED, + CONF_PROVIDERS, CONF_RESAMPLE_MODE, CONF_ROLLING_WINDOW_HOURS, CONF_RUN_WITHIN_HOURS, @@ -106,6 +107,8 @@ async_auto_detect_entity_adapter, async_auto_detect_service_adapter, async_get_energy_solar_forecast_entries, + primary_provider_config, + source_mode, ) from .source_types import SourceProviderError, SourceWindow @@ -289,7 +292,23 @@ def _default_modifier_values() -> dict[str, str]: def _source_base_defaults(source: dict[str, Any]) -> dict[str, Any]: """Return defaults for provider-specific input including fixup settings.""" - return dict(source) + provider = primary_provider_config(source) + defaults = { + **dict(provider), + **{ + key: value + for key, value in source.items() + if key not in {CONF_PROVIDERS} + }, + } + providers = source.get(CONF_PROVIDERS) + if isinstance(providers, list) and providers and source_mode(source) == SOURCE_MODE_ENTITY_ADAPTER: + defaults[CONF_WATTPLAN_ENTITY_ID] = [ + provider[CONF_WATTPLAN_ENTITY_ID] + for provider in providers + if isinstance(provider, dict) and CONF_WATTPLAN_ENTITY_ID in provider + ] + return defaults def _preferred_source_mode( @@ -337,9 +356,10 @@ def _auto_detect_step_defaults( that auto-detect was the chosen workflow, while showing what it found. """ defaults = dict(user_input) - defaults[CONF_NAME] = resolved_source.get(CONF_NAME, "") - defaults[CONF_TIME_KEY] = resolved_source.get(CONF_TIME_KEY, "") - defaults[CONF_VALUE_KEY] = resolved_source.get(CONF_VALUE_KEY, "") + provider = primary_provider_config(resolved_source) + defaults[CONF_NAME] = provider.get(CONF_NAME, "") + defaults[CONF_TIME_KEY] = provider.get(CONF_TIME_KEY, "") + defaults[CONF_VALUE_KEY] = provider.get(CONF_VALUE_KEY, "") defaults[CONF_ADAPTER_TYPE] = ADAPTER_TYPE_AUTO_DETECT return defaults @@ -661,11 +681,12 @@ def _entity_candidate_status_line( ] if compatible: best = max(compatible, key=lambda candidate: int(candidate.get("row_count", 0))) - row_count = int(best.get("row_count", 0)) path = str(best.get("path", "")) + time_key = str(best.get("time_key", "")) + value_key = str(best.get("value_key", "")) return ( - f"- ✅ Looks usable: `{entity_id}`. Found {row_count} forecast entries " - f"in `{path}`." + f"- ✅ Looks usable: `{entity_id}`. Found forecast data in `{path}` " + f"using `{time_key}` for time and `{value_key}` for value." ) best = max(candidates, key=lambda candidate: int(candidate.get("row_count", 0))) @@ -707,7 +728,10 @@ def _conflict_action_text(source: dict[str, Any]) -> str: """Return guidance when multiple compatible mappings disagree.""" source_mode = source.get(CONF_SOURCE_MODE) if source_mode == SOURCE_MODE_ENTITY_ADAPTER: - return "Use only one usable entity for this source, or switch to manual mapping." + return ( + "Remove the entities that do not match the others, or switch to manual " + "mapping." + ) if source_mode == SOURCE_MODE_SERVICE_ADAPTER: return ( "Return one consistent forecast structure from the service, or switch " @@ -728,7 +752,8 @@ def _preview_source_from_auto_detect_error( if not isinstance(detected, list) or not detected: return None - groups: dict[tuple[str, str, str], list[str]] = {} + providers: list[dict[str, Any]] = [] + entity_ids: list[str] = [] for item in detected: if not isinstance(item, dict): continue @@ -738,22 +763,25 @@ def _preview_source_from_auto_detect_error( entity_id = str(item.get("entity_id", "")) if not entity_id or not root_key or not time_key or not value_key: continue - groups.setdefault((root_key, time_key, value_key), []).append(entity_id) + entity_ids.append(entity_id) + providers.append( + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_WATTPLAN_ENTITY_ID: entity_id, + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: root_key, + CONF_TIME_KEY: time_key, + CONF_VALUE_KEY: value_key, + } + ) - if not groups: + if not providers: return None - (root_key, time_key, value_key), entity_ids = max( - groups.items(), - key=lambda item: (len(item[1]), sorted(item[1])), - ) return { **source, CONF_WATTPLAN_ENTITY_ID: entity_ids, - CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, - CONF_NAME: root_key, - CONF_TIME_KEY: time_key, - CONF_VALUE_KEY: value_key, + CONF_PROVIDERS: providers, } @@ -772,12 +800,32 @@ def _auto_detect_diagnostic_text( lines = ["**Auto-detect**"] if err is None: + lines.append("") + detected_providers = resolved_source.get(CONF_PROVIDERS) + if isinstance(detected_providers, list) and len(detected_providers) > 1: + lines.append( + "- WattPlan checked each selected entity and found usable forecast data." + ) + lines.append("") + lines.extend( + f"- ✅ Looks usable: `{provider_config.get(CONF_WATTPLAN_ENTITY_ID, 'entity')}`. " + f"Found forecast data in `{provider_config.get(CONF_NAME, '') or ''}` " + f"using `{provider_config.get(CONF_TIME_KEY, '')}` for time and " + f"`{provider_config.get(CONF_VALUE_KEY, '')}` for value." + for provider_config in detected_providers + if isinstance(provider_config, dict) + ) + return "\n".join(lines) + + provider = primary_provider_config(resolved_source) lines.extend( [ + "- WattPlan found usable forecast data automatically.", "", - f"- Root path: `{resolved_source.get(CONF_NAME, '') or ''}`", - f"- Timestamp field: `{resolved_source.get(CONF_TIME_KEY, '')}`", - f"- Value field: `{resolved_source.get(CONF_VALUE_KEY, '')}`", + f"- ✅ Looks usable. Found forecast data in " + f"`{provider.get(CONF_NAME, '') or ''}` using " + f"`{provider.get(CONF_TIME_KEY, '')}` for time and " + f"`{provider.get(CONF_VALUE_KEY, '')}` for value.", ] ) return "\n".join(lines) @@ -785,7 +833,8 @@ def _auto_detect_diagnostic_text( lines.append("") if err.details.get("diagnostic_kind") == "auto_detect_conflict": lines.append( - "- WattPlan found forecast-like data, but it could not build one consistent source from the selected input." + "- WattPlan found usable forecast data, but the selected input did not " + "resolve to one consistent structure." ) lines.append("") for detected in err.details.get("detected_mappings", []): @@ -804,10 +853,26 @@ def _auto_detect_diagnostic_text( lines.append( "- WattPlan could not build a usable forecast source from the selected input." ) + detected_mappings = err.details.get("detected_mappings") + if isinstance(detected_mappings, list) and detected_mappings: + lines.append("") + for detected in detected_mappings: + entity_id = str(detected.get("entity_id", "entity")) + root_key = str(detected.get("root_key", "")) + time_key = str(detected.get("time_key", "")) + value_key = str(detected.get("value_key", "")) + lines.append( + f"- ✅ Looks usable: `{entity_id}`. Found forecast data in `{root_key}` " + f"using `{time_key}` for time and `{value_key}` for value." + ) entity_candidates = err.details.get("entity_candidates") if isinstance(entity_candidates, dict): lines.append("") for entity_id, candidates in entity_candidates.items(): + if isinstance(detected_mappings, list) and any( + str(item.get("entity_id")) == str(entity_id) for item in detected_mappings + ): + continue lines.append( _entity_candidate_status_line( source, @@ -842,24 +907,25 @@ def _source_mode_summary(source: dict[str, Any] | None) -> str: if not isinstance(source, dict): return "Not configured" - mode = source.get(CONF_SOURCE_MODE) + mode = source_mode(source) + provider = primary_provider_config(source) if mode == SOURCE_MODE_ENTITY_ADAPTER: - entity_ids = source.get(CONF_WATTPLAN_ENTITY_ID) + entity_ids = source.get(CONF_WATTPLAN_ENTITY_ID, provider.get(CONF_WATTPLAN_ENTITY_ID)) if isinstance(entity_ids, list) and entity_ids: return f"Entity attribute: {', '.join(str(entity_id) for entity_id in entity_ids)}" if isinstance(entity_ids, str) and entity_ids: return f"Entity attribute: {entity_ids}" return "Entity attribute" if mode == SOURCE_MODE_SERVICE_ADAPTER: - service = source.get(CONF_SERVICE) + service = provider.get(CONF_SERVICE, source.get(CONF_SERVICE)) return f"Service call: {service}" if service else "Service call" if mode == SOURCE_MODE_TEMPLATE: return "Template" if mode == SOURCE_MODE_BUILT_IN: - entity_id = source.get(CONF_WATTPLAN_ENTITY_ID) + entity_id = provider.get(CONF_WATTPLAN_ENTITY_ID, source.get(CONF_WATTPLAN_ENTITY_ID)) return f"Built in: {entity_id}" if entity_id else "Built in" if mode == SOURCE_MODE_ENERGY_PROVIDER: - config_entry_id = source.get(CONF_CONFIG_ENTRY_ID) + config_entry_id = provider.get(CONF_CONFIG_ENTRY_ID, source.get(CONF_CONFIG_ENTRY_ID)) return ( f"Energy provider: {config_entry_id}" if config_entry_id @@ -1653,22 +1719,35 @@ async def _async_prepare_entity_source_input( time_key = str(manual.get(CONF_TIME_KEY, user_input.get(CONF_TIME_KEY, ""))) value_key = str(manual.get(CONF_VALUE_KEY, user_input.get(CONF_VALUE_KEY, ""))) - resolved_adapter = adapter_type - if adapter_type == ADAPTER_TYPE_AUTO_DETECT: - detected = await async_auto_detect_entity_adapter(hass, entity_ids) - resolved_adapter = ADAPTER_TYPE_ATTRIBUTE_OBJECTS - root_key = detected.root_key - time_key = detected.time_key - value_key = detected.value_key + detected_list = await async_auto_detect_entity_adapter(hass, entity_ids) + providers = [ + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_WATTPLAN_ENTITY_ID: entity_id, + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: detected.root_key, + CONF_TIME_KEY: detected.time_key, + CONF_VALUE_KEY: detected.value_key, + } + for entity_id, detected in zip(entity_ids, detected_list, strict=True) + ] + else: + providers = [ + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_WATTPLAN_ENTITY_ID: entity_id, + CONF_ADAPTER_TYPE: adapter_type, + CONF_NAME: root_key, + CONF_TIME_KEY: time_key, + CONF_VALUE_KEY: value_key, + } + for entity_id in entity_ids + ] source = { CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, - CONF_WATTPLAN_ENTITY_ID: entity_ids, - CONF_ADAPTER_TYPE: resolved_adapter, - CONF_NAME: root_key, - CONF_TIME_KEY: time_key, - CONF_VALUE_KEY: value_key, + CONF_PROVIDERS: providers, CONF_FIXUP_PROFILE: user_input[CONF_FIXUP_PROFILE], } source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) @@ -1699,11 +1778,16 @@ async def _async_prepare_service_source_input( source = { CONF_SOURCE_MODE: SOURCE_MODE_SERVICE_ADAPTER, - CONF_SERVICE: service_name, - CONF_ADAPTER_TYPE: resolved_adapter, - CONF_NAME: root_key, - CONF_TIME_KEY: time_key, - CONF_VALUE_KEY: value_key, + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_SERVICE_ADAPTER, + CONF_SERVICE: service_name, + CONF_ADAPTER_TYPE: resolved_adapter, + CONF_NAME: root_key, + CONF_TIME_KEY: time_key, + CONF_VALUE_KEY: value_key, + } + ], CONF_FIXUP_PROFILE: user_input[CONF_FIXUP_PROFILE], } source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) @@ -1766,27 +1850,30 @@ async def _async_resolve_source_for_review( ) -> tuple[dict[str, Any], dict[str, Any] | None]: """Resolve staged source config into the explicit runtime form used for validation.""" mode = source.get(CONF_SOURCE_MODE) - adapter_type = source.get(CONF_ADAPTER_TYPE) - if mode == SOURCE_MODE_ENTITY_ADAPTER and adapter_type == ADAPTER_TYPE_AUTO_DETECT: + if mode == SOURCE_MODE_ENTITY_ADAPTER: if source_input is None: raise SourceProviderError( "source_validation", - "Entity adapter auto detect is missing staged input", + "Entity adapter source is missing staged input", details={"source_mode": SOURCE_MODE_ENTITY_ADAPTER}, ) resolved = await _async_prepare_entity_source_input(hass, source_input) - return resolved, _auto_detect_step_defaults(source_input, resolved) + if source_input.get(CONF_ADAPTER_TYPE) == ADAPTER_TYPE_AUTO_DETECT: + return resolved, _auto_detect_step_defaults(source_input, resolved) + return resolved, source_input - if mode == SOURCE_MODE_SERVICE_ADAPTER and adapter_type == ADAPTER_TYPE_AUTO_DETECT: + if mode == SOURCE_MODE_SERVICE_ADAPTER: if source_input is None: raise SourceProviderError( "source_validation", - "Service adapter auto detect is missing staged input", + "Service adapter source is missing staged input", details={"source_mode": SOURCE_MODE_SERVICE_ADAPTER}, ) resolved = await _async_prepare_service_source_input(hass, source_input) - return resolved, _auto_detect_step_defaults(source_input, resolved) + if source_input.get(CONF_ADAPTER_TYPE) == ADAPTER_TYPE_AUTO_DETECT: + return resolved, _auto_detect_step_defaults(source_input, resolved) + return resolved, source_input return source, source_input @@ -2046,7 +2133,12 @@ async def _async_step_source_template( defaults = user_input source = { CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, - CONF_TEMPLATE: user_input[CONF_TEMPLATE], + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: user_input[CONF_TEMPLATE], + } + ], CONF_FIXUP_PROFILE: user_input[CONF_FIXUP_PROFILE], } source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) @@ -2148,8 +2240,13 @@ async def async_step_source_usage_built_in( defaults = user_input source = { CONF_SOURCE_MODE: SOURCE_MODE_BUILT_IN, - CONF_WATTPLAN_ENTITY_ID: user_input[CONF_WATTPLAN_ENTITY_ID], - CONF_HISTORY_DAYS: int(user_input[CONF_HISTORY_DAYS]), + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_BUILT_IN, + CONF_WATTPLAN_ENTITY_ID: user_input[CONF_WATTPLAN_ENTITY_ID], + CONF_HISTORY_DAYS: int(user_input[CONF_HISTORY_DAYS]), + } + ], } return await self._async_prepare_source_review( CONF_SOURCE_USAGE, @@ -2252,7 +2349,12 @@ async def _async_step_source_energy_provider( defaults = user_input source = { CONF_SOURCE_MODE: SOURCE_MODE_ENERGY_PROVIDER, - CONF_CONFIG_ENTRY_ID: user_input[CONF_CONFIG_ENTRY_ID], + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_ENERGY_PROVIDER, + CONF_CONFIG_ENTRY_ID: user_input[CONF_CONFIG_ENTRY_ID], + } + ], CONF_FIXUP_PROFILE: FIXUP_PROFILE_EXTEND, } source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) @@ -2869,7 +2971,12 @@ async def _async_step_source_template_options( defaults = user_input source = { CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, - CONF_TEMPLATE: user_input[CONF_TEMPLATE], + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE, + CONF_TEMPLATE: user_input[CONF_TEMPLATE], + } + ], CONF_FIXUP_PROFILE: user_input[CONF_FIXUP_PROFILE], } source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) @@ -2989,8 +3096,13 @@ async def async_step_source_usage_built_in( defaults = user_input source = { CONF_SOURCE_MODE: SOURCE_MODE_BUILT_IN, - CONF_WATTPLAN_ENTITY_ID: user_input[CONF_WATTPLAN_ENTITY_ID], - CONF_HISTORY_DAYS: int(user_input[CONF_HISTORY_DAYS]), + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_BUILT_IN, + CONF_WATTPLAN_ENTITY_ID: user_input[CONF_WATTPLAN_ENTITY_ID], + CONF_HISTORY_DAYS: int(user_input[CONF_HISTORY_DAYS]), + } + ], } return await self._async_prepare_source_review( CONF_SOURCE_USAGE, @@ -3093,7 +3205,12 @@ async def _async_step_source_energy_provider_options( defaults = user_input source = { CONF_SOURCE_MODE: SOURCE_MODE_ENERGY_PROVIDER, - CONF_CONFIG_ENTRY_ID: user_input[CONF_CONFIG_ENTRY_ID], + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_ENERGY_PROVIDER, + CONF_CONFIG_ENTRY_ID: user_input[CONF_CONFIG_ENTRY_ID], + } + ], CONF_FIXUP_PROFILE: FIXUP_PROFILE_EXTEND, } source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) diff --git a/src/custom_components/wattplan/const.py b/src/custom_components/wattplan/const.py index 2930f08..48b87f6 100644 --- a/src/custom_components/wattplan/const.py +++ b/src/custom_components/wattplan/const.py @@ -47,6 +47,7 @@ CONF_OPTIONALS = "optionals" CONF_OPTIONS_COUNT = "options" CONF_PLANNING_ENABLED = "planning_enabled" +CONF_PROVIDERS = "providers" CONF_ROLLING_WINDOW_HOURS = "rolling_window_hours" CONF_RUN_WITHIN_HOURS = "run_within_hours" CONF_RESAMPLE_MODE = "resample_mode" diff --git a/src/custom_components/wattplan/source_pipeline.py b/src/custom_components/wattplan/source_pipeline.py index b6a9a5c..d0d7877 100644 --- a/src/custom_components/wattplan/source_pipeline.py +++ b/src/custom_components/wattplan/source_pipeline.py @@ -10,6 +10,7 @@ from .const import ( CONF_FIXUP_PROFILE, CONF_HISTORY_DAYS, + CONF_PROVIDERS, CONF_SOURCE_MODE, SOURCE_MODE_ENTITY_ADAPTER, FIXUP_PROFILE_REPAIR, @@ -21,8 +22,9 @@ from .source_fixup import SourceFixupProvider, effective_provider_config from .source_provider import ( EnergySolarForecastSourceProvider, - MergedTemplateSourceProvider, + MergedSourceProvider, TemplateAdapterSourceProvider, + source_providers, ) from .source_types import SourceProvider @@ -35,6 +37,7 @@ def build_source_base_provider( source_key: str, source_config: dict[str, Any], validate_built_in_entity: ValidateBuiltInEntity = None, + allow_partial_failures: bool = False, ) -> SourceProvider: """Return the raw provider for one configured source. @@ -42,56 +45,44 @@ def build_source_base_provider( selection path so provider composition cannot drift across entry points. """ - mode = source_config.get("source_mode") mode = source_config.get(CONF_SOURCE_MODE) - if mode == SOURCE_MODE_ENTITY_ADAPTER: - entity_ids = source_config.get("entity_id") - if isinstance(entity_ids, str): - # Older saved configs stored a single entity as a string. Normalize - # so the flow and runtime both work with the same list-based shape. - source_config = {**source_config, "entity_id": [entity_ids]} - entity_ids = source_config["entity_id"] - - if isinstance(entity_ids, list) and len(entity_ids) == 1: - source_config = {**source_config, "entity_id": entity_ids[0]} - elif isinstance(entity_ids, list) and len(entity_ids) > 1: - # Merge multiple same-shaped entity sources before fixup so the - # existing alignment and fill logic sees one combined stream. - providers = [ - TemplateAdapterSourceProvider( - hass, - source_name=source_key, - source_config={**source_config, "entity_id": entity_id}, - ) - for entity_id in entity_ids - ] - return MergedTemplateSourceProvider( - providers, - hass=hass, - source_name=source_key, - source_config=source_config, - ) - if mode == SOURCE_MODE_BUILT_IN: - entity_id = str(source_config["entity_id"]) + providers_config = source_providers(source_config) + + if len(providers_config) == 1 and mode == SOURCE_MODE_BUILT_IN: + provider_config = providers_config[0] + entity_id = str(provider_config["entity_id"]) if validate_built_in_entity is not None: validate_built_in_entity(entity_id) return ForecastProvider( hass, entity_id=entity_id, - lookback_days=int(source_config.get(CONF_HISTORY_DAYS, 14)), + lookback_days=int(provider_config.get(CONF_HISTORY_DAYS, 14)), + ) + if len(providers_config) == 1 and mode == SOURCE_MODE_ENERGY_PROVIDER: + effective_config = effective_provider_config( + {**source_config, **providers_config[0]} ) - - effective_config = effective_provider_config(source_config) - if mode == SOURCE_MODE_ENERGY_PROVIDER: return EnergySolarForecastSourceProvider( hass, source_name=source_key, source_config=effective_config, ) - return TemplateAdapterSourceProvider( + if len(providers_config) == 1 and mode != SOURCE_MODE_BUILT_IN: + effective_config = effective_provider_config( + {**source_config, **providers_config[0]} + ) + return TemplateAdapterSourceProvider( + hass, + source_name=source_key, + source_config=effective_config, + ) + + return MergedSourceProvider( hass, source_name=source_key, - source_config=effective_config, + source_config=source_config, + validate_built_in_entity=validate_built_in_entity, + allow_partial_failures=allow_partial_failures, ) @@ -114,6 +105,7 @@ def build_source_value_provider( source_key=source_key, source_config=source_config, validate_built_in_entity=validate_built_in_entity, + allow_partial_failures=True, ), profile=str(source_config.get(CONF_FIXUP_PROFILE, FIXUP_PROFILE_REPAIR)), ) diff --git a/src/custom_components/wattplan/source_provider.py b/src/custom_components/wattplan/source_provider.py index a8b342c..2e129a5 100644 --- a/src/custom_components/wattplan/source_provider.py +++ b/src/custom_components/wattplan/source_provider.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta from itertools import pairwise import json +import logging from typing import Any from homeassistant.components.energy.types import GetSolarForecastType @@ -40,6 +41,8 @@ CONF_CLAMP_MODE, CONF_CONFIG_ENTRY_ID, CONF_EDGE_FILL_MODE, + CONF_HISTORY_DAYS, + CONF_PROVIDERS, CONF_RESAMPLE_MODE, CONF_SERVICE, CONF_SOURCE_MODE, @@ -51,14 +54,17 @@ RESAMPLE_MODE_FORWARD_FILL, RESAMPLE_MODE_LINEAR, RESAMPLE_MODE_NONE, + SOURCE_MODE_BUILT_IN, SOURCE_MODE_ENERGY_PROVIDER, SOURCE_MODE_ENTITY_ADAPTER, SOURCE_MODE_SERVICE_ADAPTER, SOURCE_MODE_TEMPLATE, ) +from .forecast_provider import ForecastProvider from .source_types import SourceProvider, SourceProviderError, SourceWindow CONF_WATTPLAN_ENTITY_ID = "entity_id" +_LOGGER = logging.getLogger(__name__) VALID_AGGREGATION_MODES = { AGGREGATION_MODE_FIRST, AGGREGATION_MODE_LAST, @@ -75,6 +81,45 @@ VALID_EDGE_FILL_MODES = {EDGE_FILL_MODE_NONE, EDGE_FILL_MODE_HOLD} +def source_mode(source_config: dict[str, Any]) -> str: + """Return the configured source/provider mode.""" + mode = source_config.get(CONF_SOURCE_MODE) + if isinstance(mode, str) and mode: + return mode + providers = source_config.get(CONF_PROVIDERS) + if isinstance(providers, list) and providers: + provider_mode = providers[0].get(CONF_SOURCE_MODE) + if isinstance(provider_mode, str): + return provider_mode + return "" + + +def source_providers(source_config: dict[str, Any]) -> list[dict[str, Any]]: + """Return provider configs for a source.""" + providers = source_config.get(CONF_PROVIDERS) + if isinstance(providers, list) and providers: + return [provider for provider in providers if isinstance(provider, dict)] + + if source_mode(source_config) == SOURCE_MODE_ENTITY_ADAPTER: + entity_ids = source_config.get(CONF_WATTPLAN_ENTITY_ID) + if isinstance(entity_ids, list) and entity_ids: + return [ + { + **source_config, + CONF_WATTPLAN_ENTITY_ID: str(entity_id), + } + for entity_id in entity_ids + ] + + return [source_config] + + +def primary_provider_config(source_config: dict[str, Any]) -> dict[str, Any]: + """Return the first provider config for summaries and defaults.""" + providers = source_providers(source_config) + return providers[0] if providers else {} + + async def async_get_energy_solar_forecast_platforms( hass: HomeAssistant, ) -> dict[str, GetSolarForecastType]: @@ -286,8 +331,8 @@ async def async_fetch_payload(self) -> Any: async def async_auto_detect_entity_adapter( hass: HomeAssistant, entity_ids: list[str], -) -> AdapterAutoDetectResult: - """Return one mapping that is compatible with all selected entities.""" +) -> list[AdapterAutoDetectResult]: + """Return one detected mapping for each selected entity.""" detected_mappings: list[tuple[str, AdapterAutoDetectResult]] = [] entity_candidates: dict[str, list[dict[str, Any]]] = {} for entity_id in entity_ids: @@ -322,7 +367,7 @@ async def async_auto_detect_entity_adapter( if not detected_mappings or len(detected_mappings) != len(entity_ids): raise SourceProviderError( "source_validation", - "Selected entities do not share one compatible forecast structure", + "One or more selected entities returned no compatible forecast list", details={ "entity_ids": entity_ids, "diagnostic_kind": "auto_detect_no_match", @@ -339,28 +384,7 @@ async def async_auto_detect_entity_adapter( }, ) - first_detected = detected_mappings[0][1] - if any(detected != first_detected for _, detected in detected_mappings[1:]): - raise SourceProviderError( - "source_validation", - "Selected entities do not share one compatible forecast structure", - details={ - "entity_ids": entity_ids, - "diagnostic_kind": "auto_detect_conflict", - "entity_candidates": entity_candidates, - "detected_mappings": [ - { - "entity_id": entity_id, - "root_key": detected.root_key, - "time_key": detected.time_key, - "value_key": detected.value_key, - } - for entity_id, detected in detected_mappings - ], - }, - ) - - return first_detected + return [detected for _, detected in detected_mappings] async def async_auto_detect_service_adapter( @@ -503,7 +527,7 @@ def __init__( self._resample_mode = self._resample_mode(source_config) self._edge_fill_mode = self._edge_fill_mode(source_config) - mode = source_config.get(CONF_SOURCE_MODE) + mode = source_mode(source_config) if mode == SOURCE_MODE_TEMPLATE: self._payload_provider: BasePayloadProvider = TemplatePayloadProvider( hass, source_name, source_config @@ -529,7 +553,27 @@ def __init__( async def async_values(self, window: SourceWindow) -> list[float]: """Return exactly `window.slots` values or raise error.""" + points = await self.async_points(window) + return self._points_to_values(points, window) + + async def async_fetch_payload(self) -> Any: + """Return raw payload before fixup for review and debug paths.""" + + return await self._payload_provider.async_fetch_payload() + + async def async_points(self, window: SourceWindow) -> list[dict[str, Any]]: + """Return point objects for this provider.""" payload = await self.async_fetch_payload() + return self._payload_to_points(payload, window, strict=True) + + def _payload_to_points( + self, + payload: Any, + window: SourceWindow, + *, + strict: bool, + ) -> list[dict[str, Any]]: + """Convert one provider payload into timestamp/value points.""" if not isinstance(payload, list): raise SourceProviderError( "source_parse", @@ -540,19 +584,135 @@ async def async_values(self, window: SourceWindow) -> list[float]: }, ) - if payload and isinstance(payload[0], dict): - return self._object_values(payload, window) - return self._numeric_values(payload, window) + if not payload: + return [] - async def async_fetch_payload(self) -> Any: - """Return raw payload before fixup for review and debug paths.""" - - return await self._payload_provider.async_fetch_payload() + if isinstance(payload[0], dict): + return self._object_payload_to_points(payload, strict=strict) + return self._numeric_payload_to_points(payload, window) - def _object_values(self, payload: list[Any], window: SourceWindow) -> list[float]: - """Resolve object payload into one value per requested slot.""" + def _object_payload_to_points( + self, + payload: list[Any], + *, + strict: bool, + ) -> list[dict[str, Any]]: + """Convert an object payload into point objects.""" time_key = str(self._source_config.get(CONF_TIME_KEY, "start")) value_key = str(self._source_config.get(CONF_VALUE_KEY, "value")) + points: list[dict[str, Any]] = [] + for index, point in enumerate(payload): + if not isinstance(point, dict): + if strict: + raise SourceProviderError( + "source_parse", + f"{self._source_name} point {index + 1} is not an object", + details={"source": self._source_name, "index": index}, + ) + continue + + start_value = point.get(time_key) + numeric_value = point.get(value_key) + if not isinstance(start_value, str): + if strict: + raise SourceProviderError( + "source_parse", + f"{self._source_name} point {index + 1} missing `{time_key}`", + details={"source": self._source_name, "index": index, "key": time_key}, + ) + continue + + try: + start_dt = datetime.fromisoformat(start_value) + value = float(numeric_value) + except (TypeError, ValueError) as err: + if strict: + field_name = time_key if not isinstance(start_value, str) else value_key + raise SourceProviderError( + "source_parse", + ( + f"{self._source_name} point {index + 1} has invalid " + f"`{field_name}` value" + ), + details={"source": self._source_name, "index": index}, + ) from err + continue + + points.append( + { + "start": self._as_utc(start_dt).isoformat(), + "value": value, + } + ) + return points + + def _numeric_payload_to_points( + self, + payload: list[Any], + window: SourceWindow, + ) -> list[dict[str, Any]]: + """Convert one numeric payload into timestamp/value points.""" + points: list[dict[str, Any]] = [] + start_at = self._as_utc(window.start_at) + values_per_slot = 1 + if len(payload) > window.slots: + if len(payload) % window.slots != 0: + raise SourceProviderError( + "source_validation", + ( + f"{self._source_name} source returned {len(payload)} values, " + f"which cannot be evenly aggregated into {window.slots} slots" + ), + details={ + "source": self._source_name, + "available_count": len(payload), + "required_count": window.slots, + }, + ) + values_per_slot = len(payload) // window.slots + for index, value in enumerate(payload): + try: + numeric = float(value) + except (TypeError, ValueError) as err: + raise SourceProviderError( + "source_parse", + ( + f"{self._source_name} point {index + 1} has invalid " + f"numeric value `{value}`" + ), + details={"source": self._source_name, "index": index, "value": value}, + ) from err + points.append( + { + "start": ( + start_at + timedelta(minutes=window.slot_minutes * (index // values_per_slot)) + ).isoformat(), + "value": numeric, + } + ) + return points + + def _points_to_values( + self, + payload: list[dict[str, Any]], + window: SourceWindow, + ) -> list[float]: + """Resolve point payload into one value per requested slot.""" + return self._object_values(payload, window, time_key="start", value_key="value") + + def _object_values( + self, + payload: list[Any], + window: SourceWindow, + *, + time_key: str | None = None, + value_key: str | None = None, + ) -> list[float]: + """Resolve object payload into one value per requested slot.""" + if time_key is None: + time_key = str(self._source_config.get(CONF_TIME_KEY, "start")) + if value_key is None: + value_key = str(self._source_config.get(CONF_VALUE_KEY, "value")) points: list[tuple[datetime, float]] = [] for index, point in enumerate(payload): @@ -815,37 +975,170 @@ def _edge_fill_mode(self, source_config: dict[str, Any]) -> str: return mode -class MergedTemplateSourceProvider(TemplateAdapterSourceProvider): - """Merge multiple template-style providers before fixup is applied.""" +class MergedSourceProvider(TemplateAdapterSourceProvider): + """Merge multiple providers into one shared normalization path.""" def __init__( self, - providers: list[TemplateAdapterSourceProvider], - *, hass: HomeAssistant, + *, source_name: str, source_config: dict[str, Any], + validate_built_in_entity, + allow_partial_failures: bool, ) -> None: """Initialize the merged provider.""" - super().__init__(hass, source_name=source_name, source_config=source_config) - self._providers = providers + super().__init__( + hass, + source_name=source_name, + source_config={**source_config, CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE}, + ) + self._hass = hass + self._providers = source_providers(source_config) + self._validate_built_in_entity = validate_built_in_entity + self._allow_partial_failures = allow_partial_failures async def async_fetch_payload(self) -> Any: """Return one merged list from all wrapped providers.""" - merged_payload: list[Any] = [] - for provider in self._providers: - payload = await provider.async_fetch_payload() - if not isinstance(payload, list): - raise SourceProviderError( - "source_parse", - ( - f"{self._source_name} source output from a merged provider " - "must resolve to a list" - ), - details={"source": self._source_name}, + return await self._async_collect_points( + SourceWindow(start_at=datetime.now(tz=UTC), slot_minutes=60, slots=1), + strict=True, + ) + + async def async_points(self, window: SourceWindow) -> list[dict[str, Any]]: + """Return merged point objects from all providers.""" + return await self._async_collect_points(window, strict=not self._allow_partial_failures) + + async def async_values(self, window: SourceWindow) -> list[float]: + """Return normalized values from all merged providers.""" + points = await self.async_points(window) + return self._points_to_values(points, window) + + async def _async_collect_points( + self, + window: SourceWindow, + *, + strict: bool, + ) -> list[dict[str, Any]]: + """Collect point payloads from all configured providers.""" + merged: list[dict[str, Any]] = [] + failures: list[SourceProviderError] = [] + for provider_config in self._providers: + try: + points = await self._async_provider_points( + provider_config, + window, + strict=strict, ) - merged_payload.extend(payload) - return merged_payload + except SourceProviderError as err: + failures.append(err) + if strict: + raise + self._log_provider_failure(provider_config, err) + continue + + if not points: + if strict: + raise SourceProviderError( + "source_validation", + f"{self._source_name} provider returned no usable points", + details={"source": self._source_name}, + ) + self._log_provider_empty(provider_config) + continue + + merged.extend(points) + + if merged: + return merged + if failures: + raise failures[0] + raise SourceProviderError( + "source_validation", + f"{self._source_name} source returned no usable points", + details={"source": self._source_name, "available_count": 0}, + ) + + async def _async_provider_points( + self, + provider_config: dict[str, Any], + window: SourceWindow, + *, + strict: bool, + ) -> list[dict[str, Any]]: + """Return points for one provider config.""" + mode = source_mode(provider_config) + provider_source_config = {**self._source_config, **provider_config} + if mode == SOURCE_MODE_BUILT_IN: + entity_id = str(provider_config[CONF_WATTPLAN_ENTITY_ID]) + if self._validate_built_in_entity is not None: + self._validate_built_in_entity(entity_id) + provider = ForecastProvider( + self._hass, + entity_id=entity_id, + lookback_days=int(provider_config.get(CONF_HISTORY_DAYS, 14)), + ) + values = await provider.async_values(window) + slot_delta = timedelta(minutes=window.slot_minutes) + start_at = self._as_utc(window.start_at) + return [ + { + "start": (start_at + (slot_delta * index)).isoformat(), + "value": value, + } + for index, value in enumerate(values) + ] + + if mode == SOURCE_MODE_ENERGY_PROVIDER: + provider = EnergySolarForecastSourceProvider( + self._hass, + source_name=self._source_name, + source_config=provider_source_config, + ) + payload = await provider.async_fetch_payload() + return provider._payload_to_points(payload, window, strict=strict) + + provider = TemplateAdapterSourceProvider( + self._hass, + source_name=self._source_name, + source_config=provider_source_config, + ) + payload = await provider.async_fetch_payload() + return provider._payload_to_points(payload, window, strict=strict) + + def _log_provider_empty(self, provider_config: dict[str, Any]) -> None: + """Log when one provider contributes no usable points.""" + _LOGGER.warning( + "%s provider `%s` produced 0 usable points", + self._source_name, + self._provider_label(provider_config), + ) + + def _log_provider_failure( + self, + provider_config: dict[str, Any], + err: SourceProviderError, + ) -> None: + """Log when one provider fails but another can still cover the source.""" + _LOGGER.warning( + "%s provider `%s` failed during merged source resolution: %s", + self._source_name, + self._provider_label(provider_config), + err, + ) + + def _provider_label(self, provider_config: dict[str, Any]) -> str: + """Return one compact provider identifier.""" + mode = source_mode(provider_config) + if mode == SOURCE_MODE_ENTITY_ADAPTER: + return str(provider_config.get(CONF_WATTPLAN_ENTITY_ID, "entity")) + if mode == SOURCE_MODE_SERVICE_ADAPTER: + return str(provider_config.get(CONF_SERVICE, "service")) + if mode == SOURCE_MODE_BUILT_IN: + return str(provider_config.get(CONF_WATTPLAN_ENTITY_ID, "built_in")) + if mode == SOURCE_MODE_ENERGY_PROVIDER: + return str(provider_config.get(CONF_CONFIG_ENTRY_ID, "energy_provider")) + return mode or "provider" class EnergySolarForecastSourceProvider(TemplateAdapterSourceProvider): diff --git a/src/custom_components/wattplan/translations/en.json b/src/custom_components/wattplan/translations/en.json index 34f60d9..048d75f 100644 --- a/src/custom_components/wattplan/translations/en.json +++ b/src/custom_components/wattplan/translations/en.json @@ -75,7 +75,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "fixup_profile": "Direct only uses only clean input. Repair local gaps also fills small gaps near known values. Extend daily pattern can also repeat values from 24 hours earlier when enough data already exists." }, @@ -205,7 +205,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "fixup_profile": "Direct only uses only clean input. Repair local gaps also fills small gaps near known values. Extend daily pattern can also repeat values from 24 hours earlier when enough data already exists." }, @@ -398,7 +398,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "fixup_profile": "Direct only uses only clean input. Repair local gaps also fills small gaps near known values. Extend daily pattern can also repeat values from 24 hours earlier when enough data already exists." }, @@ -542,7 +542,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "fixup_profile": "Direct only uses only clean input. Repair local gaps also fills small gaps near known values. Extend daily pattern can also repeat values from 24 hours earlier when enough data already exists." }, @@ -679,7 +679,7 @@ "attribute_name_required": "An attribute name is required for this adapter.", "invalid_payload": "Source output must be a list of numeric values or a list of objects containing timestamp and value fields.", "auto_detect_no_match": "Auto detect did not find a compatible forecast list. Review the candidate paths and pick manual mapping if needed.", - "auto_detect_conflict": "Auto detect found forecast data, but the selected entities did not resolve to the same root path and fields.", + "auto_detect_conflict": "Auto detect found forecast data, but the selected input did not resolve to one consistent structure. Review the detected mappings and adjust the selection or switch to manual mapping if needed.", "built_in_no_numeric_history": "No usable numeric history was found for the selected load sensor in the configured lookback period.", "review_ready": "This source is **ready** for the selected horizon. ✅", "review_ready_extended": "This source is usable for the selected horizon, but only **{raw_coverage_summary}** came from the source itself. WattPlan repaired or extended it to cover the full plan. ⚠️ If you can provide more native forecast data, go back and choose a source with longer coverage.", @@ -787,7 +787,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "name": "Attribute path that contains the forecast list. Ignored in auto detect mode.", "time_key": "Key containing the timestamp. Ignored in auto detect mode.", @@ -868,7 +868,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "name": "Attribute path that contains the forecast list. Ignored in auto detect mode.", "time_key": "Key containing the timestamp. Ignored in auto detect mode.", @@ -961,7 +961,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "name": "Attribute path that contains the forecast list. Ignored in auto detect mode.", "time_key": "Key containing the timestamp. Ignored in auto detect mode.", @@ -1038,7 +1038,7 @@ "fixup_profile": "Fixup profile" }, "data_description": { - "entity_id": "One or more entities to inspect. Auto detect checks them in order and uses the first compatible forecast list it finds.", + "entity_id": "One or more entities to inspect. Auto detect checks each selected entity and keeps one detected mapping per usable entity.", "adapter_type": "Choose Auto detect for the recommended setup path, or choose an explicit adapter type to enter the fields yourself.", "name": "Attribute path that contains the forecast list. Ignored in auto detect mode.", "time_key": "Key containing the timestamp. Ignored in auto detect mode.", @@ -1400,7 +1400,7 @@ "attribute_name_required": "An attribute name is required for this adapter.", "invalid_payload": "Source output must be a list of numeric values or a list of objects containing timestamp and value fields.", "auto_detect_no_match": "Auto detect did not find a compatible forecast list. Review the candidate paths and pick manual mapping if needed.", - "auto_detect_conflict": "Auto detect found forecast data, but the selected entities did not resolve to the same root path and fields.", + "auto_detect_conflict": "Auto detect found forecast data, but the selected input did not resolve to one consistent structure. Review the detected mappings and adjust the selection or switch to manual mapping if needed.", "built_in_no_numeric_history": "No usable numeric history was found for the selected load sensor in the configured lookback period.", "invalid_start": "One or more entries has an invalid timestamp. Check the configured time key and use ISO format with timezone.", "invalid_value": "One or more entries has a non-numeric value. Check the configured value key and output numeric values.", diff --git a/tests/integration/test_source_flow_modifiers.py b/tests/integration/test_source_flow_modifiers.py index df970a8..476ba71 100644 --- a/tests/integration/test_source_flow_modifiers.py +++ b/tests/integration/test_source_flow_modifiers.py @@ -22,6 +22,7 @@ CONF_FIXUP_PROFILE, CONF_HISTORY_DAYS, CONF_HOURS_TO_PLAN, + CONF_PROVIDERS, CONF_RESAMPLE_MODE, CONF_SERVICE, CONF_SLOT_MINUTES, @@ -275,9 +276,10 @@ async def test_options_flow_persists_price_adapter_modifiers( updated = hass.config_entries.async_get_entry(entry.entry_id) assert updated is not None price = updated.data[CONF_SOURCES][CONF_SOURCE_IMPORT_PRICE] + provider = price[CONF_PROVIDERS][0] assert price[CONF_SOURCE_MODE] == SOURCE_MODE_ENTITY_ADAPTER - assert price[CONF_ADAPTER_TYPE] == ADAPTER_TYPE_ATTRIBUTE_VALUES - assert price[CONF_NAME] == "prices" + assert provider[CONF_ADAPTER_TYPE] == ADAPTER_TYPE_ATTRIBUTE_VALUES + assert provider[CONF_NAME] == "prices" assert price[CONF_FIXUP_PROFILE] == FIXUP_PROFILE_EXTEND assert price[CONF_AGGREGATION_MODE] == AGGREGATION_MODE_MAX assert price[CONF_CLAMP_MODE] == CLAMP_MODE_NONE @@ -361,6 +363,21 @@ async def test_config_flow_auto_detects_entity_adapter( }, ) assert result["step_id"] == "source_review" + diagnostic_text = result["description_placeholders"]["diagnostic_text"] + assert ( + "- WattPlan checked each selected entity and found usable forecast data." + in diagnostic_text + ) + assert ( + "✅ Looks usable: `sensor.first`. Found forecast data in `prices.home` using " + "`starts_at` for time and `amount` for value." + in diagnostic_text + ) + assert ( + "✅ Looks usable: `sensor.second`. Found forecast data in `prices.home` using " + "`starts_at` for time and `amount` for value." + in diagnostic_text + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"accept_source_summary": True} ) @@ -378,11 +395,18 @@ async def test_config_flow_auto_detects_entity_adapter( entry = hass.config_entries.async_entries(DOMAIN)[0] price = entry.data[CONF_SOURCES][CONF_SOURCE_IMPORT_PRICE] assert price[CONF_SOURCE_MODE] == SOURCE_MODE_ENTITY_ADAPTER - assert price["entity_id"] == ["sensor.first", "sensor.second"] - assert price[CONF_ADAPTER_TYPE] == ADAPTER_TYPE_ATTRIBUTE_OBJECTS - assert price[CONF_NAME] == "prices.home" - assert price["time_key"] == "starts_at" - assert price["value_key"] == "amount" + assert len(price[CONF_PROVIDERS]) == 2 + assert [provider["entity_id"] for provider in price[CONF_PROVIDERS]] == [ + "sensor.first", + "sensor.second", + ] + assert all( + provider[CONF_ADAPTER_TYPE] == ADAPTER_TYPE_ATTRIBUTE_OBJECTS + for provider in price[CONF_PROVIDERS] + ) + assert {provider[CONF_NAME] for provider in price[CONF_PROVIDERS]} == {"prices.home"} + assert {provider["time_key"] for provider in price[CONF_PROVIDERS]} == {"starts_at"} + assert {provider["value_key"] for provider in price[CONF_PROVIDERS]} == {"amount"} async def test_config_flow_persists_explicit_multi_entity_adapter( @@ -466,10 +490,13 @@ async def test_config_flow_persists_explicit_multi_entity_adapter( entry = hass.config_entries.async_entries(DOMAIN)[0] price = entry.data[CONF_SOURCES][CONF_SOURCE_IMPORT_PRICE] - assert price["entity_id"] == ["sensor.today", "sensor.tomorrow"] - assert price[CONF_NAME] == "detailedForecast" - assert price["time_key"] == "period_start" - assert price["value_key"] == "pv_estimate" + assert [provider["entity_id"] for provider in price[CONF_PROVIDERS]] == [ + "sensor.today", + "sensor.tomorrow", + ] + assert {provider[CONF_NAME] for provider in price[CONF_PROVIDERS]} == {"detailedForecast"} + assert {provider["time_key"] for provider in price[CONF_PROVIDERS]} == {"period_start"} + assert {provider["value_key"] for provider in price[CONF_PROVIDERS]} == {"pv_estimate"} async def test_config_flow_routes_failed_entity_auto_detect_to_review( @@ -513,12 +540,87 @@ async def test_config_flow_routes_failed_entity_auto_detect_to_review( assert result["step_id"] == "source_review" assert result["errors"] == {"base": "auto_detect_no_match"} assert result["data_schema"].schema == {} + assert ( + result["description_placeholders"]["diagnostic_text"] + == "**Auto-detect**\n\n" + "- WattPlan could not build a usable forecast source from the selected input.\n\n" + "- ❌ Not usable: `sensor.bad_prices`. Found list data in `prices`, but no " + "timestamp field WattPlan can use. Ensure you picked an entity with forecast " + "data in its attributes, or switch to manual mapping." + ) result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "source_price_adapter" +async def test_config_flow_failed_entity_auto_detect_previews_usable_providers( + hass: HomeAssistant, +) -> None: + """Review preview should preserve detected providers after partial auto-detect failure.""" + start = datetime.now(tz=UTC).replace(minute=0, second=0, microsecond=0) + hass.states.async_set( + "sensor.good_prices", + "ok", + { + "prices": { + "home": [ + { + "starts_at": (start + timedelta(hours=hour)).isoformat(), + "amount": float(hour + 1), + } + for hour in range(6) + ] + } + }, + ) + hass.states.async_set("sensor.bad_prices", "ok", {"prices": [{"foo": "bar"}]}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Partial auto detect preview", + CONF_SLOT_MINUTES: "60", + CONF_HOURS_TO_PLAN: "12", + }, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER}, + ) + assert result["step_id"] == "source_price_adapter" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "entity_id": ["sensor.good_prices", "sensor.bad_prices"], + CONF_ADAPTER_TYPE: ADAPTER_TYPE_AUTO_DETECT, + SECTION_SOURCE_MANUAL: { + CONF_NAME: "", + "time_key": "", + "value_key": "", + }, + CONF_FIXUP_PROFILE: FIXUP_PROFILE_REPAIR, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "source_review" + assert result["errors"] == {"base": "auto_detect_no_match"} + assert ( + result["description_placeholders"]["raw_coverage_summary"] + == "6 usable intervals, 12 needed, 60-minute resolution" + ) + assert ( + "✅ Looks usable: `sensor.good_prices`. Found forecast data in `prices.home` using `starts_at` " + "for time and `amount` for value." + in result["description_placeholders"]["diagnostic_text"] + ) + + async def test_options_flow_auto_detects_service_adapter( hass: HomeAssistant, ) -> None: @@ -584,12 +686,13 @@ async def _handle_prices(call: ServiceCall) -> dict[str, object]: updated = hass.config_entries.async_get_entry(entry.entry_id) assert updated is not None price = updated.data[CONF_SOURCES][CONF_SOURCE_IMPORT_PRICE] + provider = price[CONF_PROVIDERS][0] assert price[CONF_SOURCE_MODE] == SOURCE_MODE_SERVICE_ADAPTER - assert price[CONF_SERVICE] == "test.prices" - assert price[CONF_ADAPTER_TYPE] == ADAPTER_TYPE_SERVICE_RESPONSE - assert price[CONF_NAME] == "prices.home" - assert price["time_key"] == "start_time" - assert price["value_key"] == "price" + assert provider[CONF_SERVICE] == "test.prices" + assert provider[CONF_ADAPTER_TYPE] == ADAPTER_TYPE_SERVICE_RESPONSE + assert provider[CONF_NAME] == "prices.home" + assert provider["time_key"] == "start_time" + assert provider["value_key"] == "price" async def test_options_flow_routes_failed_service_auto_detect_to_review( @@ -728,8 +831,8 @@ async def test_config_flow_persists_usage_built_in_source( assert result["type"] is FlowResultType.CREATE_ENTRY usage = result["data"][CONF_SOURCES][CONF_SOURCE_USAGE] assert usage[CONF_SOURCE_MODE] == SOURCE_MODE_BUILT_IN - assert usage["entity_id"] == "sensor.house_load_kwh" - assert usage[CONF_HISTORY_DAYS] == 14 + assert usage[CONF_PROVIDERS][0]["entity_id"] == "sensor.house_load_kwh" + assert usage[CONF_PROVIDERS][0][CONF_HISTORY_DAYS] == 14 async def test_built_in_usage_source_shows_sensor_validation_error( @@ -909,7 +1012,7 @@ async def test_config_flow_persists_pv_energy_provider_source( created_entry = hass.config_entries.async_entries(DOMAIN)[0] pv = created_entry.data[CONF_SOURCES][CONF_SOURCE_PV] assert pv[CONF_SOURCE_MODE] == SOURCE_MODE_ENERGY_PROVIDER - assert pv[CONF_CONFIG_ENTRY_ID] == entry.entry_id + assert pv[CONF_PROVIDERS][0][CONF_CONFIG_ENTRY_ID] == entry.entry_id assert pv[CONF_FIXUP_PROFILE] == FIXUP_PROFILE_EXTEND assert pv[CONF_AGGREGATION_MODE] == AGGREGATION_MODE_MAX assert pv[CONF_CLAMP_MODE] == CLAMP_MODE_NONE diff --git a/tests/integration/test_source_provider.py b/tests/integration/test_source_provider.py index f58d7b6..8aed6ea 100644 --- a/tests/integration/test_source_provider.py +++ b/tests/integration/test_source_provider.py @@ -22,6 +22,7 @@ CONF_CLAMP_MODE, CONF_CONFIG_ENTRY_ID, CONF_EDGE_FILL_MODE, + CONF_PROVIDERS, CONF_RESAMPLE_MODE, CONF_SERVICE, CONF_SOURCE_MODE, @@ -214,7 +215,7 @@ async def test_entity_adapter_provider_returns_values(hass: HomeAssistant) -> No async def test_entity_adapter_auto_detects_nested_attribute( hass: HomeAssistant, ) -> None: - """Auto detect should resolve one mapping shared by selected entities.""" + """Auto detect should resolve one mapping per selected entity.""" hass.states.async_set( "sensor.first", "ok", @@ -246,9 +247,10 @@ async def test_entity_adapter_auto_detects_nested_attribute( detected = await async_auto_detect_entity_adapter(hass, ["sensor.first", "sensor.second"]) - assert detected.root_key == "prices.home" - assert detected.time_key == "starts_at" - assert detected.value_key == "total" + assert len(detected) == 2 + assert {item.root_key for item in detected} == {"prices.home"} + assert {item.time_key for item in detected} == {"starts_at"} + assert {item.value_key for item in detected} == {"total"} async def test_entity_adapter_provider_merges_multiple_entities( @@ -287,6 +289,74 @@ async def test_entity_adapter_provider_merges_multiple_entities( ] }, ) + provider = build_source_base_provider( + hass, + source_key="solar", + source_config={ + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + "entity_id": "sensor.today", + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: "detailedForecast", + "time_key": "period_start", + "value_key": "pv_estimate", + }, + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + "entity_id": "sensor.tomorrow", + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: "detailedForecast", + "time_key": "period_start", + "value_key": "pv_estimate", + }, + ], + }, + ) + + values = await provider.async_values(_hour_window()) + + assert values == [1.0, 2.0, 3.0, 4.0] + + +async def test_legacy_multi_entity_adapter_config_still_merges_entities( + hass: HomeAssistant, +) -> None: + """Old multi-entity entity-adapter config should normalize into merged providers.""" + hass.states.async_set( + "sensor.today", + "ok", + { + "detailedForecast": [ + { + "period_start": "2026-01-01T00:00:00+00:00", + "pv_estimate": 1.0, + }, + { + "period_start": "2026-01-01T01:00:00+00:00", + "pv_estimate": 2.0, + }, + ] + }, + ) + hass.states.async_set( + "sensor.tomorrow", + "ok", + { + "detailedForecast": [ + { + "period_start": "2026-01-01T02:00:00+00:00", + "pv_estimate": 3.0, + }, + { + "period_start": "2026-01-01T03:00:00+00:00", + "pv_estimate": 4.0, + }, + ] + }, + ) + provider = build_source_base_provider( hass, source_key="solar", @@ -305,6 +375,74 @@ async def test_entity_adapter_provider_merges_multiple_entities( assert values == [1.0, 2.0, 3.0, 4.0] +async def test_merged_provider_tolerates_one_empty_entity_provider( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Runtime source resolution should tolerate one provider producing no points.""" + today_points = [ + { + "price": float(index + 1), + "start": f"2026-03-14T{index // 4:02d}:{(index % 4) * 15:02d}:00+00:00", + "end": f"2026-03-14T{index // 4:02d}:{((index % 4) + 1) * 15 if (index % 4) < 3 else 0:02d}:00+00:00", + } + for index in range(96) + ] + hass.states.async_set( + "sensor.stromligning_current_price_vat", + "ok", + {"prices": today_points}, + ) + hass.states.async_set( + "binary_sensor.stromligning_tomorrow_available_vat", + "off", + { + "available_at": "13:22:18", + "forecast_data": False, + "prices": [{"end": "2026-03-15T00:00:00+01:00"}], + }, + ) + + provider = build_source_base_provider( + hass, + source_key="import_price", + source_config={ + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_AGGREGATION_MODE: AGGREGATION_MODE_MEAN, + CONF_PROVIDERS: [ + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + "entity_id": "sensor.stromligning_current_price_vat", + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: "prices", + "time_key": "start", + "value_key": "price", + }, + { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + "entity_id": "binary_sensor.stromligning_tomorrow_available_vat", + CONF_ADAPTER_TYPE: ADAPTER_TYPE_ATTRIBUTE_OBJECTS, + CONF_NAME: "prices", + "time_key": "start", + "value_key": "price", + }, + ], + }, + allow_partial_failures=True, + ) + + values = await provider.async_values( + SourceWindow( + start_at=datetime(2026, 3, 14, 0, 0, tzinfo=UTC), + slot_minutes=15, + slots=96, + ) + ) + + assert len(values) == 96 + assert values[:4] == [1.0, 2.0, 3.0, 4.0] + assert "produced 0 usable points" in caplog.text + + async def test_service_adapter_provider_returns_values(hass: HomeAssistant) -> None: """Service adapter should return nested service response values."""