diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efd5490..48bb6fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - "**" + - main pull_request: jobs: diff --git a/src/custom_components/wattplan/adapter_auto.py b/src/custom_components/wattplan/adapter_auto.py index 2f51d6e..27c72e3 100644 --- a/src/custom_components/wattplan/adapter_auto.py +++ b/src/custom_components/wattplan/adapter_auto.py @@ -18,6 +18,19 @@ class AdapterAutoDetectResult: value_key: str +@dataclass(frozen=True, slots=True) +class AdapterCandidateSummary: + """Summary of one candidate list considered during auto-detect.""" + + path: str + row_count: int + sample_type: str + timestamp_keys: tuple[str, ...] + numeric_keys: tuple[str, ...] + compatible: bool + reason: str + + def resolve_nested_value(root: Any, path: str) -> Any: """Resolve a dotted path from a payload root, allowing an empty path.""" if not path: @@ -163,6 +176,85 @@ def detect_object_list_mapping(payload: list[Any]) -> tuple[str, str] | None: return (start_key, next(iter(value_keys))) +def summarize_candidate_list(path: str, payload: list[Any]) -> AdapterCandidateSummary: + """Return one human-readable summary for a candidate list.""" + if not payload: + return AdapterCandidateSummary( + path=path, + row_count=0, + sample_type="empty", + timestamp_keys=(), + numeric_keys=(), + compatible=False, + reason="list is empty", + ) + + sample_type = type(payload[0]).__name__ + timestamp_keys: set[str] = set() + numeric_keys: set[str] = set() + matching_rows = 0 + object_rows = 0 + missing_timestamp = False + missing_numeric = False + ambiguous_numeric = False + + for item in payload: + if not isinstance(item, dict): + continue + object_rows += 1 + row_timestamp_keys = { + key for key, value in item.items() if _coerce_timestamp(value) is not None + } + row_numeric_keys = { + key for key, value in item.items() if _coerce_decimal(value) is not None + } + timestamp_keys.update(row_timestamp_keys) + numeric_keys.update(row_numeric_keys) + + value_key = _select_numeric_value_key(row_numeric_keys) + if not row_timestamp_keys: + missing_timestamp = True + if not row_numeric_keys: + missing_numeric = True + elif value_key is None: + ambiguous_numeric = True + elif len(row_timestamp_keys) in {1, 2}: + matching_rows += 1 + + if not object_rows: + reason = f"items are {sample_type}, not objects" + elif matching_rows > 0: + reason = "compatible" + elif not timestamp_keys: + reason = "rows have no timestamp-like fields" + elif not numeric_keys or missing_numeric: + reason = "rows have no numeric value fields" + elif ambiguous_numeric: + reason = "rows have multiple numeric fields, so one value key could not be chosen" + elif missing_timestamp: + reason = "rows do not share one usable timestamp field" + else: + reason = "rows do not match the expected timestamp/value object shape" + + return AdapterCandidateSummary( + path=path, + row_count=len(payload), + sample_type=sample_type, + timestamp_keys=tuple(sorted(timestamp_keys)), + numeric_keys=tuple(sorted(numeric_keys)), + compatible=matching_rows > 0, + reason=reason, + ) + + +def summarize_auto_detect_candidates(root: Any) -> list[AdapterCandidateSummary]: + """Return candidate summaries for every list found in a payload root.""" + return [ + summarize_candidate_list(path, payload) + for path, payload in iter_candidate_lists(root) + ] + + def auto_detect_mapping(root: Any) -> AdapterAutoDetectResult | None: """Walk a payload recursively and return the strongest compatible list. diff --git a/src/custom_components/wattplan/config_flow.py b/src/custom_components/wattplan/config_flow.py index 3396d25..f0a5db9 100644 --- a/src/custom_components/wattplan/config_flow.py +++ b/src/custom_components/wattplan/config_flow.py @@ -152,6 +152,19 @@ def _format_coverage_datetime( return parsed.astimezone(timezone).strftime("%Y-%m-%d %H:%M") +async def _coverage_placeholder_text( + hass: HomeAssistant, + *, + summary: dict[str, Any], + timezone_name: str | None, + field: str, +) -> str: + """Return a formatted coverage placeholder or a no-data label.""" + if int(summary.get("available_count", 0)) <= 0: + return await _async_config_translation(hass, "review_no_data") + return _format_coverage_datetime(summary.get(field, "Unknown"), timezone_name) + + def _subentry_display_title(subentry_type: str, data: dict[str, Any]) -> str: """Build a concise display title for a subentry.""" name = data[CONF_NAME] @@ -532,10 +545,48 @@ def _coverage_from_available_count( return start_at, start_at + (available_count * timedelta(minutes=slot_minutes)) +def _format_coverage_summary( + available_count: int, *, expected_slots: int, slot_minutes: int +) -> str: + """Return one short human-readable coverage summary.""" + return ( + f"{available_count} usable intervals, {expected_slots} needed, " + f"{slot_minutes}-minute resolution" + ) + + +async def _async_provider_available_count( + hass: HomeAssistant, + *, + core_data: dict[str, Any], + key: str, + source: dict[str, Any], + floor_to_slot, + validate_built_in_entity, +) -> int: + """Return the available interval count for one provider configuration.""" + slot_minutes = int(core_data[CONF_SLOT_MINUTES]) + expected_slots = _expected_slots(core_data) + window = SourceWindow( + start_at=floor_to_slot(datetime.now(tz=UTC), slot_minutes), + slot_minutes=slot_minutes, + slots=expected_slots, + ) + provider = build_source_base_provider( + hass, + source_key=key, + source_config=source, + validate_built_in_entity=validate_built_in_entity, + ) + values = await provider.async_values(window) + return len(values) + + def _invalid_key_from_source_error(err: SourceProviderError) -> str: """Map source provider errors to flow translation keys.""" built_in_reason = err.details.get("built_in_reason") + diagnostic_kind = err.details.get("diagnostic_kind") if err.code == "source_fetch": if "config_entry_id" in err.details: return "energy_provider_unavailable" @@ -550,13 +601,242 @@ def _invalid_key_from_source_error(err: SourceProviderError) -> str: if "rendered a string" in str(err): return "template_invalid_structure" return "invalid_payload" - if err.code == "source_validation" and ( - "entity_ids" in err.details or "service" in err.details - ): - return "invalid_payload" + if err.code == "source_validation": + if diagnostic_kind == "auto_detect_no_match": + return "auto_detect_no_match" + if diagnostic_kind == "auto_detect_conflict": + return "auto_detect_conflict" + if "entity_ids" in err.details or "service" in err.details: + return "invalid_payload" return "not_enough_values" +def _candidate_summary_line(candidate: dict[str, Any]) -> str: + """Return one compact markdown bullet for an auto-detect candidate.""" + path = str(candidate.get("path", "")) + row_count = int(candidate.get("row_count", 0)) + reason = str(candidate.get("reason", "not compatible")) + timestamp_keys = ", ".join(str(key) for key in candidate.get("timestamp_keys", [])) + numeric_keys = ", ".join(str(key) for key in candidate.get("numeric_keys", [])) + + detail_parts = [f"{row_count} rows", reason] + if timestamp_keys: + detail_parts.append(f"timestamps: {timestamp_keys}") + if numeric_keys: + detail_parts.append(f"numeric: {numeric_keys}") + return f"- `{path}`: {'; '.join(detail_parts)}" + + +def _auto_detect_action_text(source: dict[str, Any]) -> str: + """Return a user-facing next action for the active source mode.""" + source_mode = source.get(CONF_SOURCE_MODE) + if source_mode == SOURCE_MODE_ENTITY_ADAPTER: + return ( + "Ensure you picked an entity with forecast data in its attributes, " + "or switch to manual mapping." + ) + if source_mode == SOURCE_MODE_SERVICE_ADAPTER: + return ( + "Ensure the service returns forecast data in its response, " + "or switch to manual mapping." + ) + return ( + "Switch to manual mapping and confirm the root path, timestamp field, " + "and value field." + ) + + +def _entity_candidate_status_line( + source: dict[str, Any], entity_id: str, candidates: list[dict[str, Any]] +) -> str: + """Return a user-facing diagnostic line for one selected entity.""" + if not candidates: + return ( + f"- ❌ Not usable: `{entity_id}`. WattPlan did not find any list-like " + f"forecast data to inspect. {_auto_detect_action_text(source)}" + ) + + compatible = [ + candidate for candidate in candidates if str(candidate.get("reason")) == "compatible" + ] + 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", "")) + return ( + f"- ✅ Looks usable: `{entity_id}`. Found {row_count} forecast entries " + f"in `{path}`." + ) + + best = max(candidates, key=lambda candidate: int(candidate.get("row_count", 0))) + path = str(best.get("path", "")) + reason = str(best.get("reason", "not compatible")) + + if reason == "rows have no numeric value fields": + problem = f"Found timestamp-like data in `{path}`, but no price/value field." + elif reason == "rows have no timestamp-like fields": + problem = f"Found list data in `{path}`, but no timestamp field WattPlan can use." + elif reason == "rows have multiple numeric fields, so one value key could not be chosen": + problem = ( + f"Found data in `{path}`, but more than one numeric field looked like " + "the value." + ) + elif reason == "rows do not share one usable timestamp field": + problem = ( + f"Found data in `{path}`, but the rows do not share one consistent " + "timestamp field." + ) + elif reason == "list is empty": + problem = f"Found `{path}`, but the list is empty." + elif reason.startswith("items are "): + sample_type = reason.removeprefix("items are ").removesuffix(", not objects") + problem = ( + f"Found `{path}`, but its items are `{sample_type}` values rather than " + "timestamped forecast objects." + ) + else: + problem = ( + f"Found data in `{path}`, but WattPlan could not recognize it as " + "forecast rows." + ) + + return f"- ❌ Not usable: `{entity_id}`. {problem} {_auto_detect_action_text(source)}" + + +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." + if source_mode == SOURCE_MODE_SERVICE_ADAPTER: + return ( + "Return one consistent forecast structure from the service, or switch " + "to manual mapping." + ) + return "Use one consistent forecast structure, or switch to manual mapping." + + +def _preview_source_from_auto_detect_error( + source: dict[str, Any], + err: SourceProviderError, +) -> dict[str, Any] | None: + """Build a best-effort preview source from usable auto-detect matches.""" + if source.get(CONF_SOURCE_MODE) != SOURCE_MODE_ENTITY_ADAPTER: + return None + + detected = err.details.get("detected_mappings") + if not isinstance(detected, list) or not detected: + return None + + groups: dict[tuple[str, str, str], list[str]] = {} + for item in detected: + if not isinstance(item, dict): + continue + root_key = str(item.get("root_key", "")) + time_key = str(item.get("time_key", "")) + value_key = str(item.get("value_key", "")) + 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) + + if not groups: + 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, + } + + +def _auto_detect_diagnostic_text( + source: dict[str, Any], + source_input: dict[str, Any] | None, + resolved_source: dict[str, Any], + err: SourceProviderError | None, +) -> str: + """Return markdown describing auto-detect findings for the review page.""" + adapter_type = None + if source_input is not None: + adapter_type = source_input.get(CONF_ADAPTER_TYPE) + if adapter_type != ADAPTER_TYPE_AUTO_DETECT: + return "" + + lines = ["**Auto-detect**"] + if err is None: + lines.extend( + [ + "", + 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, '')}`", + ] + ) + return "\n".join(lines) + + 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." + ) + lines.append("") + for detected in err.details.get("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." + ) + lines.append("") + lines.append(f"- ⚠️ Next step: {_conflict_action_text(source)}") + return "\n".join(lines) + + lines.append( + "- WattPlan could not build a usable forecast source from the selected input." + ) + entity_candidates = err.details.get("entity_candidates") + if isinstance(entity_candidates, dict): + lines.append("") + for entity_id, candidates in entity_candidates.items(): + lines.append( + _entity_candidate_status_line( + source, + str(entity_id), + list(candidates), + ) + ) + else: + candidates = err.details.get("candidates", []) + lines.append("") + if not candidates: + lines.append( + "- ❌ Not usable. WattPlan did not find any list-like forecast data " + f"to inspect. {_auto_detect_action_text(source)}" + ) + else: + best = max( + list(candidates), + key=lambda candidate: int(candidate.get("row_count", 0)), + ) + lines.append( + f" {_candidate_summary_line(best)}" + ) + lines.append( + f"- ⚠️ Next step: {_auto_detect_action_text(source)}" + ) + return "\n".join(lines) + + def _source_mode_summary(source: dict[str, Any] | None) -> str: """Return a short human-readable summary of the selected source mode.""" if not isinstance(source, dict): @@ -595,6 +875,7 @@ async def _async_source_summary( core_data: dict[str, Any], key: str, source: dict[str, Any], + source_input: dict[str, Any] | None, floor_to_slot, validate_built_in_entity, ) -> dict[str, Any]: @@ -606,15 +887,40 @@ async def _async_source_summary( available_count = 0 coverage_start = start_at coverage_end = start_at + raw_available_count = 0 + raw_coverage_start = start_at + raw_coverage_end = start_at history_coverage_days = 0.0 - mode = source.get(CONF_SOURCE_MODE) + error_key: str | None = None + source_error: SourceProviderError | None = None + resolved_input = source_input + has_preview_source = False + + try: + resolved_source, resolved_input = await _async_resolve_source_for_review( + hass, source=source, source_input=source_input + ) + except SourceProviderError as err: + resolved_source = source + error_key = _invalid_key_from_source_error(err) + is_valid = False + source_error = err + if preview_source := _preview_source_from_auto_detect_error(source, err): + resolved_source = preview_source + has_preview_source = True + if source_input is not None: + resolved_input = _auto_detect_step_defaults(source_input, preview_source) + else: + is_valid = True + + mode = resolved_source.get(CONF_SOURCE_MODE) try: if mode == SOURCE_MODE_BUILT_IN: provider = build_source_base_provider( hass, source_key=key, - source_config=source, + source_config=resolved_source, validate_built_in_entity=validate_built_in_entity, ) if not isinstance(provider, ForecastProvider): @@ -635,37 +941,74 @@ async def _async_source_summary( except (SourceProviderError, vol.Invalid): pass - error_key: str | None = None - try: - available_count = await _async_validate_source_values( - hass, - core_data=core_data, - key=key, - source=source, - floor_to_slot=floor_to_slot, - validate_built_in_entity=validate_built_in_entity, - ) - is_valid = True - coverage_start, coverage_end = _coverage_from_available_count( - start_at, - slot_minutes=slot_minutes, - available_count=available_count, - ) - except SourceProviderError as err: - error_key = _invalid_key_from_source_error(err) - is_valid = False - if available_from_error := err.details.get("available_count"): - available_count = int(available_from_error) + if error_key is None or has_preview_source: + raw_source = {**resolved_source, CONF_FIXUP_PROFILE: FIXUP_PROFILE_STRICT} + try: + raw_available_count = await _async_provider_available_count( + hass, + core_data=core_data, + key=key, + source=raw_source, + floor_to_slot=floor_to_slot, + validate_built_in_entity=validate_built_in_entity, + ) + raw_coverage_start, raw_coverage_end = _coverage_from_available_count( + start_at, + slot_minutes=slot_minutes, + available_count=raw_available_count, + ) + except SourceProviderError as err: + if available_from_error := err.details.get("available_count"): + raw_available_count = int(available_from_error) + raw_coverage_start, raw_coverage_end = _coverage_from_available_count( + start_at, + slot_minutes=slot_minutes, + available_count=raw_available_count, + ) + + try: + available_count = await _async_validate_source_values( + hass, + core_data=core_data, + key=key, + source=resolved_source, + floor_to_slot=floor_to_slot, + validate_built_in_entity=validate_built_in_entity, + ) coverage_start, coverage_end = _coverage_from_available_count( start_at, slot_minutes=slot_minutes, available_count=available_count, ) + except SourceProviderError as err: + error_key = _invalid_key_from_source_error(err) + is_valid = False + source_error = err + if available_from_error := err.details.get("available_count"): + available_count = int(available_from_error) + coverage_start, coverage_end = _coverage_from_available_count( + start_at, + slot_minutes=slot_minutes, + available_count=available_count, + ) history_warning = False review_text_key = "review_ready" review_text_placeholders: dict[str, str] | None = None - if mode == SOURCE_MODE_BUILT_IN and history_coverage_days < 7: + if ( + is_valid + and available_count >= expected_slots + and raw_available_count < expected_slots + ): + review_text_key = "review_ready_extended" + review_text_placeholders = { + "raw_coverage_summary": _format_coverage_summary( + raw_available_count, + expected_slots=expected_slots, + slot_minutes=slot_minutes, + ) + } + elif mode == SOURCE_MODE_BUILT_IN and history_coverage_days < 7: history_warning = True review_text_key = "review_limited_history" review_text_placeholders = {"history_days": f"{history_coverage_days:.1f}"} @@ -680,15 +1023,37 @@ async def _async_source_summary( review_text_key, placeholders=review_text_placeholders, ) + diagnostic_text = _auto_detect_diagnostic_text( + source, + resolved_input, + resolved_source, + source_error, + ) return { "available_count": available_count, "coverage_start": coverage_start.isoformat(), "coverage_end": coverage_end.isoformat(), + "coverage_summary": _format_coverage_summary( + available_count, + expected_slots=expected_slots, + slot_minutes=slot_minutes, + ), + "raw_available_count": raw_available_count, + "raw_coverage_start": raw_coverage_start.isoformat(), + "raw_coverage_end": raw_coverage_end.isoformat(), + "raw_coverage_summary": _format_coverage_summary( + raw_available_count, + expected_slots=expected_slots, + slot_minutes=slot_minutes, + ), "review_text": review_text, + "diagnostic_text": diagnostic_text, "is_valid": is_valid, "error_key": error_key, "history_warning": history_warning, + "resolved_source": resolved_source, + "resolved_source_input": resolved_input, } @@ -1345,6 +1710,87 @@ async def _async_prepare_service_source_input( return source +def _source_from_entity_adapter_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + """Return staged entity adapter config without resolving auto-detect.""" + selected_entities = user_input[CONF_WATTPLAN_ENTITY_ID] + if isinstance(selected_entities, str): + entity_ids = [selected_entities] + else: + entity_ids = [str(entity_id) for entity_id in selected_entities] + + manual = user_input.get(SECTION_SOURCE_MANUAL, {}) + if not isinstance(manual, dict): + manual = {} + + source = { + CONF_SOURCE_MODE: SOURCE_MODE_ENTITY_ADAPTER, + CONF_WATTPLAN_ENTITY_ID: entity_ids, + CONF_ADAPTER_TYPE: str(user_input[CONF_ADAPTER_TYPE]), + CONF_NAME: str(manual.get(CONF_NAME, user_input.get(CONF_NAME, ""))), + CONF_TIME_KEY: str(manual.get(CONF_TIME_KEY, user_input.get(CONF_TIME_KEY, ""))), + CONF_VALUE_KEY: str( + manual.get(CONF_VALUE_KEY, user_input.get(CONF_VALUE_KEY, "")) + ), + CONF_FIXUP_PROFILE: user_input[CONF_FIXUP_PROFILE], + } + source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) + return source + + +def _source_from_service_adapter_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + """Return staged service adapter config without resolving auto-detect.""" + manual = user_input.get(SECTION_SOURCE_MANUAL, {}) + if not isinstance(manual, dict): + manual = {} + + source = { + CONF_SOURCE_MODE: SOURCE_MODE_SERVICE_ADAPTER, + CONF_SERVICE: str(user_input[CONF_SERVICE]), + CONF_ADAPTER_TYPE: str(user_input[CONF_ADAPTER_TYPE]), + CONF_NAME: str(manual.get(CONF_NAME, user_input.get(CONF_NAME, ""))), + CONF_TIME_KEY: str(manual.get(CONF_TIME_KEY, user_input.get(CONF_TIME_KEY, ""))), + CONF_VALUE_KEY: str( + manual.get(CONF_VALUE_KEY, user_input.get(CONF_VALUE_KEY, "")) + ), + CONF_FIXUP_PROFILE: user_input[CONF_FIXUP_PROFILE], + } + source.update(user_input.get(SECTION_SOURCE_ADVANCED, {})) + return source + + +async def _async_resolve_source_for_review( + hass: HomeAssistant, + *, + source: dict[str, Any], + source_input: dict[str, Any] | None, +) -> 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 source_input is None: + raise SourceProviderError( + "source_validation", + "Entity adapter auto detect 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 mode == SOURCE_MODE_SERVICE_ADAPTER and adapter_type == ADAPTER_TYPE_AUTO_DETECT: + if source_input is None: + raise SourceProviderError( + "source_validation", + "Service adapter auto detect 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) + + return source, source_input + + class WattPlanConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for WattPlan.""" @@ -1739,21 +2185,12 @@ async def _async_step_source_adapter( defaults = user_input errors.update(_validate_source_adapter_input(user_input)) if not errors: - try: - source = await self._async_prepare_entity_source(user_input) - source_input = ( - _auto_detect_step_defaults(user_input, source) - if user_input.get(CONF_ADAPTER_TYPE) == ADAPTER_TYPE_AUTO_DETECT - else user_input - ) - return await self._async_prepare_source_review( - key, - source, - source_step_id=step_id, - source_input=source_input, - ) - except SourceProviderError as err: - errors["base"] = _invalid_key_from_source_error(err) + return await self._async_prepare_source_review( + key, + _source_from_entity_adapter_user_input(user_input), + source_step_id=step_id, + source_input=user_input, + ) return self.async_show_form( step_id=step_id, @@ -1765,18 +2202,6 @@ async def _async_step_source_adapter( last_step=False, ) - async def _async_prepare_entity_source( - self, user_input: dict[str, Any] - ) -> dict[str, Any]: - """Return explicit entity adapter config, resolving auto-detect if needed.""" - return await _async_prepare_entity_source_input(self.hass, user_input) - - async def _async_prepare_service_source( - self, user_input: dict[str, Any] - ) -> dict[str, Any]: - """Return explicit service adapter config, resolving auto-detect if needed.""" - return await _async_prepare_service_source_input(self.hass, user_input) - async def _async_step_source_service( self, key: str, @@ -1793,21 +2218,12 @@ async def _async_step_source_service( defaults = user_input errors.update(_validate_service_adapter_input(user_input)) if not errors: - try: - source = await self._async_prepare_service_source(user_input) - source_input = ( - _auto_detect_step_defaults(user_input, source) - if user_input.get(CONF_ADAPTER_TYPE) == ADAPTER_TYPE_AUTO_DETECT - else user_input - ) - return await self._async_prepare_source_review( - key, - source, - source_step_id=step_id, - source_input=source_input, - ) - except SourceProviderError as err: - errors["base"] = _invalid_key_from_source_error(err) + return await self._async_prepare_source_review( + key, + _source_from_service_adapter_user_input(user_input), + source_step_id=step_id, + source_input=user_input, + ) return self.async_show_form( step_id=step_id, @@ -1930,9 +2346,13 @@ async def _async_prepare_source_review( core_data=self._core, key=key, source=source, + source_input=source_input, floor_to_slot=self._floor_to_slot, validate_built_in_entity=self._validate_built_in_usage_entity, ) + self._pending_source_input = self._pending_source_summary.get( + "resolved_source_input", source_input + ) return await self.async_step_source_review() async def async_step_source_review( @@ -1945,6 +2365,7 @@ async def async_step_source_review( key = self._pending_source_key pending = dict(self._pending_source) summary = self._pending_source_summary or {} + resolved_pending = dict(summary.get("resolved_source", pending)) is_valid = bool(summary.get("is_valid", False)) defaults = { CONF_ACCEPT_SOURCE_SUMMARY: is_valid, @@ -1960,7 +2381,7 @@ async def async_step_source_review( if not user_input[CONF_ACCEPT_SOURCE_SUMMARY]: return await self._async_return_to_pending_source_step() try: - await self._async_validate_source(key, pending) + await self._async_validate_source(key, resolved_pending) except vol.Invalid as err: errors["base"] = str(err) self._pending_source_summary = await _async_source_summary( @@ -1968,14 +2389,16 @@ async def async_step_source_review( core_data=self._core, key=key, source=pending, + source_input=self._pending_source_input, floor_to_slot=self._floor_to_slot, validate_built_in_entity=self._validate_built_in_usage_entity, ) summary = self._pending_source_summary or {} + resolved_pending = dict(summary.get("resolved_source", pending)) is_valid = bool(summary.get("is_valid", False)) defaults[CONF_ACCEPT_SOURCE_SUMMARY] = is_valid else: - self._sources[key] = pending + self._sources[key] = resolved_pending self._pending_source_key = None self._pending_source = None self._pending_source_input = None @@ -1997,20 +2420,34 @@ async def async_step_source_review( errors=errors, description_placeholders={ **self._source_description_placeholders(key), - "coverage_start": _format_coverage_datetime( - summary.get("coverage_start", "Unknown"), - self.hass.config.time_zone, + "raw_coverage_start": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="raw_coverage_start", ), - "coverage_end": _format_coverage_datetime( - summary.get("coverage_end", "Unknown"), - self.hass.config.time_zone, + "raw_coverage_end": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="raw_coverage_end", ), - "coverage_summary": ( - f"{summary.get('available_count', 0)} usable intervals, " - f"{_expected_slots(self._core)} needed, " - f"{self._core[CONF_SLOT_MINUTES]}-minute resolution" + "raw_coverage_summary": str(summary.get("raw_coverage_summary", "")), + "adjusted_coverage_start": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="coverage_start", + ), + "adjusted_coverage_end": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="coverage_end", ), + "adjusted_coverage_summary": str(summary.get("coverage_summary", "")), "review_text": str(summary.get("review_text", "")), + "diagnostic_text": str(summary.get("diagnostic_text", "")), "accept_note": accept_note, }, last_step=self._is_final_source_step(key), @@ -2589,23 +3026,12 @@ async def _async_step_source_adapter_options( defaults = user_input errors.update(_validate_source_adapter_input(user_input)) if not errors: - try: - source = await _async_prepare_entity_source_input( - self.hass, user_input - ) - source_input = ( - _auto_detect_step_defaults(user_input, source) - if user_input.get(CONF_ADAPTER_TYPE) == ADAPTER_TYPE_AUTO_DETECT - else user_input - ) - return await self._async_prepare_source_review( - key, - source, - source_step_id=step_id, - source_input=source_input, - ) - except SourceProviderError as err: - errors["base"] = _invalid_key_from_source_error(err) + return await self._async_prepare_source_review( + key, + _source_from_entity_adapter_user_input(user_input), + source_step_id=step_id, + source_input=user_input, + ) return self.async_show_form( step_id=step_id, @@ -2633,23 +3059,12 @@ async def _async_step_source_service_options( defaults = user_input errors.update(_validate_service_adapter_input(user_input)) if not errors: - try: - source = await _async_prepare_service_source_input( - self.hass, user_input - ) - source_input = ( - _auto_detect_step_defaults(user_input, source) - if user_input.get(CONF_ADAPTER_TYPE) == ADAPTER_TYPE_AUTO_DETECT - else user_input - ) - return await self._async_prepare_source_review( - key, - source, - source_step_id=step_id, - source_input=source_input, - ) - except SourceProviderError as err: - errors["base"] = _invalid_key_from_source_error(err) + return await self._async_prepare_source_review( + key, + _source_from_service_adapter_user_input(user_input), + source_step_id=step_id, + source_input=user_input, + ) return self.async_show_form( step_id=step_id, @@ -2772,9 +3187,13 @@ async def _async_prepare_source_review( core_data=self._data, key=key, source=source, + source_input=source_input, floor_to_slot=self._floor_to_slot, validate_built_in_entity=self._validate_built_in_usage_entity, ) + self._pending_source_input = self._pending_source_summary.get( + "resolved_source_input", source_input + ) return await self.async_step_source_review() async def async_step_source_review( @@ -2787,6 +3206,7 @@ async def async_step_source_review( key = self._pending_source_key pending = dict(self._pending_source) summary = self._pending_source_summary or {} + resolved_pending = dict(summary.get("resolved_source", pending)) is_valid = bool(summary.get("is_valid", False)) defaults = { CONF_ACCEPT_SOURCE_SUMMARY: is_valid, @@ -2802,7 +3222,7 @@ async def async_step_source_review( if not user_input[CONF_ACCEPT_SOURCE_SUMMARY]: return await self._async_return_to_pending_source_step() try: - await self._async_validate_source(key, pending) + await self._async_validate_source(key, resolved_pending) except vol.Invalid as err: errors["base"] = str(err) self._pending_source_summary = await _async_source_summary( @@ -2810,15 +3230,17 @@ async def async_step_source_review( core_data=self._data, key=key, source=pending, + source_input=self._pending_source_input, floor_to_slot=self._floor_to_slot, validate_built_in_entity=self._validate_built_in_usage_entity, ) summary = self._pending_source_summary or {} + resolved_pending = dict(summary.get("resolved_source", pending)) is_valid = bool(summary.get("is_valid", False)) defaults[CONF_ACCEPT_SOURCE_SUMMARY] = is_valid else: sources = dict(self._data.get(CONF_SOURCES, {})) - sources[key] = pending + sources[key] = resolved_pending self._data[CONF_SOURCES] = sources self.hass.config_entries.async_update_entry( self.config_entry, data=self._data @@ -2844,20 +3266,34 @@ async def async_step_source_review( errors=errors, description_placeholders={ **self._source_description_placeholders(key), - "coverage_start": _format_coverage_datetime( - summary.get("coverage_start", "Unknown"), - self.hass.config.time_zone, + "raw_coverage_start": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="raw_coverage_start", + ), + "raw_coverage_end": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="raw_coverage_end", ), - "coverage_end": _format_coverage_datetime( - summary.get("coverage_end", "Unknown"), - self.hass.config.time_zone, + "raw_coverage_summary": str(summary.get("raw_coverage_summary", "")), + "adjusted_coverage_start": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="coverage_start", ), - "coverage_summary": ( - f"{summary.get('available_count', 0)} usable intervals, " - f"{_expected_slots(self._data)} needed, " - f"{self._data[CONF_SLOT_MINUTES]}-minute resolution" + "adjusted_coverage_end": await _coverage_placeholder_text( + self.hass, + summary=summary, + timezone_name=self.hass.config.time_zone, + field="coverage_end", ), + "adjusted_coverage_summary": str(summary.get("coverage_summary", "")), "review_text": str(summary.get("review_text", "")), + "diagnostic_text": str(summary.get("diagnostic_text", "")), "accept_note": accept_note, }, ) diff --git a/src/custom_components/wattplan/source_provider.py b/src/custom_components/wattplan/source_provider.py index 3338ae9..a8b342c 100644 --- a/src/custom_components/wattplan/source_provider.py +++ b/src/custom_components/wattplan/source_provider.py @@ -22,6 +22,7 @@ AdapterAutoDetectResult, auto_detect_mapping, resolve_nested_value, + summarize_auto_detect_candidates, ) from .const import ( ADAPTER_TYPE_ATTRIBUTE_OBJECTS, @@ -287,7 +288,8 @@ async def async_auto_detect_entity_adapter( entity_ids: list[str], ) -> AdapterAutoDetectResult: """Return one mapping that is compatible with all selected entities.""" - detected_mappings: list[AdapterAutoDetectResult] = [] + detected_mappings: list[tuple[str, AdapterAutoDetectResult]] = [] + entity_candidates: dict[str, list[dict[str, Any]]] = {} for entity_id in entity_ids: state = hass.states.get(entity_id) if state is None: @@ -300,22 +302,62 @@ async def async_auto_detect_entity_adapter( root = dict(state.attributes) with suppress(json.JSONDecodeError): root["state_json"] = json.loads(state.state) + entity_candidates[entity_id] = [ + { + "path": summary.path or "", + "row_count": summary.row_count, + "sample_type": summary.sample_type, + "timestamp_keys": list(summary.timestamp_keys), + "numeric_keys": list(summary.numeric_keys), + "compatible": summary.compatible, + "reason": summary.reason, + } + for summary in summarize_auto_detect_candidates(root) + ] detected = auto_detect_mapping(root) - if detected is None: - raise SourceProviderError( - "source_validation", - "Selected entities do not share one compatible forecast structure", - details={"entity_ids": entity_ids}, - ) - detected_mappings.append(detected) + if detected is not None: + detected_mappings.append((entity_id, detected)) + + if not detected_mappings or len(detected_mappings) != len(entity_ids): + raise SourceProviderError( + "source_validation", + "Selected entities do not share one compatible forecast structure", + details={ + "entity_ids": entity_ids, + "diagnostic_kind": "auto_detect_no_match", + "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 + ], + }, + ) - first_detected = detected_mappings[0] - if any(detected != first_detected for detected in detected_mappings[1:]): + 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}, + 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 @@ -342,12 +384,28 @@ async def async_auto_detect_service_adapter( blocking=True, return_response=True, ) + candidates = [ + { + "path": summary.path or "", + "row_count": summary.row_count, + "sample_type": summary.sample_type, + "timestamp_keys": list(summary.timestamp_keys), + "numeric_keys": list(summary.numeric_keys), + "compatible": summary.compatible, + "reason": summary.reason, + } + for summary in summarize_auto_detect_candidates(response) + ] detected = auto_detect_mapping(response) if detected is None: raise SourceProviderError( "source_validation", f"Service `{service_name}` returned no compatible forecast list", - details={"service": service_name}, + details={ + "service": service_name, + "diagnostic_kind": "auto_detect_no_match", + "candidates": candidates, + }, ) return detected diff --git a/src/custom_components/wattplan/strings.json b/src/custom_components/wattplan/strings.json index ee5af29..34f60d9 100644 --- a/src/custom_components/wattplan/strings.json +++ b/src/custom_components/wattplan/strings.json @@ -658,7 +658,7 @@ }, "source_review": { "title": "Review source coverage", - "description": "**Coverage summary**\n\n- The source provided **{coverage_summary}**\n- Coverage starts at **{coverage_start}**\n- Coverage ends at **{coverage_end}**\n\n**Review**\n\n{review_text}\n\n{accept_note}", + "description": "**Raw source coverage**\n\n- The source itself provided **{raw_coverage_summary}**\n- Raw coverage starts at **{raw_coverage_start}**\n- Raw coverage ends at **{raw_coverage_end}**\n\n**Adjusted planner coverage**\n\n- After WattPlan repair/fill, the planner has **{adjusted_coverage_summary}**\n- Adjusted coverage starts at **{adjusted_coverage_start}**\n- Adjusted coverage ends at **{adjusted_coverage_end}**\n\n**Review**\n\n{review_text}\n\n{diagnostic_text}\n\n{accept_note}", "submit": "Next", "data": { "accept_source_summary": "I accept this source setup" @@ -678,13 +678,17 @@ "built_in_requires_energy_kwh": "Select an energy sensor with unit `kWh` for the built-in usage forecast.", "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.", "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.", "review_limited_history": "This source is usable, but only {history_days} days of history were found. Forecast quality may be poor.", "review_invalid": "", "review_incomplete": "This source does not cover the full planning horizon. ❌ Choose a fixup profile to repair gaps or extend the tail.", "review_has_gaps": "This source has gaps between known samples. ❌ A fixup profile is recommended.", "review_has_gaps_repaired": "This source has gaps between known samples, but the selected fixup profile will repair them for planning. ✅", + "review_no_data": "No data", "review_accept_note_valid": "Acceptance is already enabled because this source meets the current requirements. Disable it to return and adjust the input.", "review_accept_note_invalid": "Next will bring you back to the source input page so you can adjust the provider or fixup settings.", "invalid_start": "One or more entries has an invalid timestamp. Check the configured time key and use ISO format with timezone.", @@ -1094,7 +1098,7 @@ }, "source_review": { "title": "Review source coverage", - "description": "**Coverage summary**\n\n- The source provided **{coverage_summary}**\n- Coverage starts at **{coverage_start}**\n- Coverage ends at **{coverage_end}**\n\n**Review**\n\n{review_text}\n\n{accept_note}", + "description": "**Raw source coverage**\n\n- The source itself provided **{raw_coverage_summary}**\n- Raw coverage starts at **{raw_coverage_start}**\n- Raw coverage ends at **{raw_coverage_end}**\n\n**Adjusted planner coverage**\n\n- After WattPlan repair/fill, the planner has **{adjusted_coverage_summary}**\n- Adjusted coverage starts at **{adjusted_coverage_start}**\n- Adjusted coverage ends at **{adjusted_coverage_end}**\n\n**Review**\n\n{review_text}\n\n{diagnostic_text}\n\n{accept_note}", "submit": "Next", "data": { "accept_source_summary": "I accept this source setup" @@ -1395,6 +1399,8 @@ "built_in_requires_energy_kwh": "Select an energy sensor with unit `kWh` for the built-in usage forecast.", "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.", "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/src/custom_components/wattplan/translations/en.json b/src/custom_components/wattplan/translations/en.json index ee5af29..34f60d9 100644 --- a/src/custom_components/wattplan/translations/en.json +++ b/src/custom_components/wattplan/translations/en.json @@ -658,7 +658,7 @@ }, "source_review": { "title": "Review source coverage", - "description": "**Coverage summary**\n\n- The source provided **{coverage_summary}**\n- Coverage starts at **{coverage_start}**\n- Coverage ends at **{coverage_end}**\n\n**Review**\n\n{review_text}\n\n{accept_note}", + "description": "**Raw source coverage**\n\n- The source itself provided **{raw_coverage_summary}**\n- Raw coverage starts at **{raw_coverage_start}**\n- Raw coverage ends at **{raw_coverage_end}**\n\n**Adjusted planner coverage**\n\n- After WattPlan repair/fill, the planner has **{adjusted_coverage_summary}**\n- Adjusted coverage starts at **{adjusted_coverage_start}**\n- Adjusted coverage ends at **{adjusted_coverage_end}**\n\n**Review**\n\n{review_text}\n\n{diagnostic_text}\n\n{accept_note}", "submit": "Next", "data": { "accept_source_summary": "I accept this source setup" @@ -678,13 +678,17 @@ "built_in_requires_energy_kwh": "Select an energy sensor with unit `kWh` for the built-in usage forecast.", "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.", "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.", "review_limited_history": "This source is usable, but only {history_days} days of history were found. Forecast quality may be poor.", "review_invalid": "", "review_incomplete": "This source does not cover the full planning horizon. ❌ Choose a fixup profile to repair gaps or extend the tail.", "review_has_gaps": "This source has gaps between known samples. ❌ A fixup profile is recommended.", "review_has_gaps_repaired": "This source has gaps between known samples, but the selected fixup profile will repair them for planning. ✅", + "review_no_data": "No data", "review_accept_note_valid": "Acceptance is already enabled because this source meets the current requirements. Disable it to return and adjust the input.", "review_accept_note_invalid": "Next will bring you back to the source input page so you can adjust the provider or fixup settings.", "invalid_start": "One or more entries has an invalid timestamp. Check the configured time key and use ISO format with timezone.", @@ -1094,7 +1098,7 @@ }, "source_review": { "title": "Review source coverage", - "description": "**Coverage summary**\n\n- The source provided **{coverage_summary}**\n- Coverage starts at **{coverage_start}**\n- Coverage ends at **{coverage_end}**\n\n**Review**\n\n{review_text}\n\n{accept_note}", + "description": "**Raw source coverage**\n\n- The source itself provided **{raw_coverage_summary}**\n- Raw coverage starts at **{raw_coverage_start}**\n- Raw coverage ends at **{raw_coverage_end}**\n\n**Adjusted planner coverage**\n\n- After WattPlan repair/fill, the planner has **{adjusted_coverage_summary}**\n- Adjusted coverage starts at **{adjusted_coverage_start}**\n- Adjusted coverage ends at **{adjusted_coverage_end}**\n\n**Review**\n\n{review_text}\n\n{diagnostic_text}\n\n{accept_note}", "submit": "Next", "data": { "accept_source_summary": "I accept this source setup" @@ -1395,6 +1399,8 @@ "built_in_requires_energy_kwh": "Select an energy sensor with unit `kWh` for the built-in usage forecast.", "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.", "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 126b973..df970a8 100644 --- a/tests/integration/test_source_flow_modifiers.py +++ b/tests/integration/test_source_flow_modifiers.py @@ -167,6 +167,62 @@ async def test_config_flow_persists_price_template_modifiers( assert price[CONF_EDGE_FILL_MODE] == EDGE_FILL_MODE_HOLD +async def test_source_review_shows_raw_and_adjusted_coverage( + hass: HomeAssistant, +) -> None: + """Review should distinguish native coverage from repaired planner coverage.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "requirements" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["step_id"] == "planner_setup" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Coverage review", + CONF_SLOT_MINUTES: "60", + CONF_HOURS_TO_PLAN: "24", + }, + ) + assert result["step_id"] == "source_price" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_SOURCE_MODE: SOURCE_MODE_TEMPLATE} + ) + assert result["step_id"] == "source_price_template" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TEMPLATE: _numeric_template(12), + CONF_FIXUP_PROFILE: FIXUP_PROFILE_REPAIR, + "advanced": { + CONF_AGGREGATION_MODE: AGGREGATION_MODE_MIN, + CONF_CLAMP_MODE: CLAMP_MODE_NEAREST, + CONF_RESAMPLE_MODE: RESAMPLE_MODE_FORWARD_FILL, + CONF_EDGE_FILL_MODE: EDGE_FILL_MODE_HOLD, + }, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "source_review" + assert ( + result["description_placeholders"]["raw_coverage_summary"] + == "12 usable intervals, 24 needed, 60-minute resolution" + ) + assert ( + result["description_placeholders"]["adjusted_coverage_summary"] + == "24 usable intervals, 24 needed, 60-minute resolution" + ) + assert "only **12 usable intervals, 24 needed, 60-minute resolution** came from the source itself" in result[ + "description_placeholders" + ]["review_text"] + + async def test_options_flow_persists_price_adapter_modifiers( hass: HomeAssistant, ) -> None: @@ -416,6 +472,53 @@ async def test_config_flow_persists_explicit_multi_entity_adapter( assert price["value_key"] == "pv_estimate" +async def test_config_flow_routes_failed_entity_auto_detect_to_review( + hass: HomeAssistant, +) -> None: + """Entity adapter semantic failures should be reported on review.""" + 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: "Auto detect failure", + 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.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["data_schema"].schema == {} + + 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_options_flow_auto_detects_service_adapter( hass: HomeAssistant, ) -> None: @@ -489,6 +592,54 @@ async def _handle_prices(call: ServiceCall) -> dict[str, object]: assert price["value_key"] == "price" +async def test_options_flow_routes_failed_service_auto_detect_to_review( + hass: HomeAssistant, +) -> None: + """Service adapter semantic failures should be reported on review.""" + + async def _handle_bad_prices(call: ServiceCall) -> dict[str, object]: + return {"prices": [{"foo": "bar"}]} + + hass.services.async_register( + "test", + "bad_prices", + _handle_bad_prices, + supports_response=SupportsResponse.ONLY, + ) + entry = await _create_entry_with_price_template(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "source_price"} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {CONF_SOURCE_MODE: SOURCE_MODE_SERVICE_ADAPTER} + ) + assert result["step_id"] == "source_price_service" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_SERVICE: "test.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["data_schema"].schema == {} + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "source_price_service" + + async def test_config_flow_persists_usage_built_in_source( hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch ) -> None: