Forecast support for 24h and 48h degree days estimates#63
Forecast support for 24h and 48h degree days estimates#63alepee wants to merge 6 commits intorelease/1.1.0from
Conversation
… support - Added support for weather entities to enable forecast-based degree days estimates. - Introduced new sensors for estimated HDD/CDD for today and tomorrow. - Implemented automatic migration of existing entities to a new unique_id format to prevent conflicts. - Updated configuration flow to validate weather entities and handle reconfiguration. - Improved error handling and logging for weather entity issues. - Added translations and documentation updates for new features.
- Added functionality to create and delete issues related to weather entities that do not support hourly forecasts. - Introduced a new repair flow for users to select a new weather entity or remove the existing one. - Updated translations for the new repair options and issue descriptions in both English and French. - Refactored existing repair flow methods to accommodate the new structure and improve user experience.
… integration - Introduced async migration for config entries to update unique_ids, preventing conflicts with multiple instances. - Updated configuration flow to support new naming conventions for titles based on heating and cooling settings. - Improved logging for migration processes and entity handling. - Added constants for new default names and updated versioning in config flow.
There was a problem hiding this comment.
Pull Request Overview
This PR adds weather forecast support for estimating degree days 24h and 48h ahead, introduces a reconfiguration flow, implements a repairs platform for weather entity issues, and fixes unique_id conflicts for multiple integration instances.
Key Changes
- Adds optional weather entity configuration with forecast-based sensors (HDD/CDD Estimated Today/Tomorrow)
- Implements reconfiguration flow to update integration settings without removal
- Adds repairs platform to handle weather entities that lose hourly forecast support
- Updates unique_id format to include entry_id, preventing conflicts with multiple instances
- Includes automatic migration to preserve entity history
Reviewed Changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| setup.cfg | Version bump to 1.1.0 |
| custom_components/heating_cooling_degree_days/manifest.json | Version bump to 1.1.0 |
| custom_components/heating_cooling_degree_days/translations/en.json | Added translations for forecast sensors, reconfiguration, and repairs |
| custom_components/heating_cooling_degree_days/translations/fr.json | Added French translations for all new features |
| custom_components/heating_cooling_degree_days/sensor.py | Added forecast sensor creation and updated unique_id format |
| custom_components/heating_cooling_degree_days/repairs.py | New repairs platform for weather entity issues |
| custom_components/heating_cooling_degree_days/migrations/entity_unique_ids.py | Entity unique_id migration logic |
| custom_components/heating_cooling_degree_days/migrations/init.py | Migration module exports |
| custom_components/heating_cooling_degree_days/coordinator.py | Weather forecast fetching and today/tomorrow estimation calculations |
| custom_components/heating_cooling_degree_days/const.py | Added forecast sensor type constants and weather entity config |
| custom_components/heating_cooling_degree_days/config_flow.py | Weather entity validation and reconfiguration flow |
| custom_components/heating_cooling_degree_days/calculations.py | Forecast-based calculation functions |
| custom_components/heating_cooling_degree_days/init.py | Integration setup with migration calls and weather state listener |
| README.md | Documentation for forecast features |
| CHANGELOG.md | Version 1.1.0 changes documented |
| Makefile | Version bump to 1.1.0 |
Comments suppressed due to low confidence (1)
custom_components/heating_cooling_degree_days/repairs.py:26
- Mixing implicit and explicit returns may indicate an error, as implicit returns always return None.
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
|
|
There was a problem hiding this comment.
The async_create_fix_flow function doesn't explicitly return a value when no repair flow class supports the given issue_id. This will cause the function to return None, which could lead to errors. Add an explicit return statement or raise an appropriate exception when no matching repair flow is found.
| raise ValueError(f"No repair flow found for issue_id: {issue_id}") |
| # Get the weather entity from the component to call async_forecast_hourly | ||
| component = self.hass.data.get(WEATHER_DOMAIN) | ||
| if not component: | ||
| _LOGGER.warning( | ||
| "Weather component is not available. " | ||
| "This may happen if the weather component is not fully loaded." | ||
| ) | ||
| return None | ||
|
|
||
| # Find the weather entity | ||
| weather_entity_obj = component.get_entity(self.weather_entity) | ||
| if not weather_entity_obj: | ||
| _LOGGER.warning( | ||
| "Weather entity %s not found in component", self.weather_entity | ||
| ) | ||
| return None | ||
|
|
||
| # Call the async_forecast_hourly method directly | ||
| # This returns a list of Forecast TypedDict objects (which are dictionaries) | ||
| forecast_list = await weather_entity_obj.async_forecast_hourly() |
There was a problem hiding this comment.
The method component.get_entity() is being called on hass.data.get(WEATHER_DOMAIN), but this assumes the weather component data structure supports a get_entity() method. In Home Assistant, the component data in hass.data is typically an EntityComponent, but accessing entities this way is not the standard approach.
Consider using hass.states.async_all() to find the entity or using the entity registry to get the entity object. Alternatively, you could use the service call approach with weather.get_forecasts service which is the recommended way to get forecast data in recent Home Assistant versions.
| # Get the weather entity from the component to call async_forecast_hourly | |
| component = self.hass.data.get(WEATHER_DOMAIN) | |
| if not component: | |
| _LOGGER.warning( | |
| "Weather component is not available. " | |
| "This may happen if the weather component is not fully loaded." | |
| ) | |
| return None | |
| # Find the weather entity | |
| weather_entity_obj = component.get_entity(self.weather_entity) | |
| if not weather_entity_obj: | |
| _LOGGER.warning( | |
| "Weather entity %s not found in component", self.weather_entity | |
| ) | |
| return None | |
| # Call the async_forecast_hourly method directly | |
| # This returns a list of Forecast TypedDict objects (which are dictionaries) | |
| forecast_list = await weather_entity_obj.async_forecast_hourly() | |
| # Call the weather.get_forecasts service to get hourly forecast data | |
| response = await self.hass.services.async_call( | |
| WEATHER_DOMAIN, | |
| "get_forecasts", | |
| { | |
| "entity_id": self.weather_entity, | |
| "type": "hourly", | |
| }, | |
| return_response=True, | |
| ) | |
| forecast_list = None | |
| if response and isinstance(response, dict): | |
| forecast_list = response.get("forecast") |
| SENSOR_TYPE_HDD_ESTIMATED_TODAY, | ||
| SENSOR_TYPE_HDD_ESTIMATED_TOMORROW, | ||
| SENSOR_TYPE_CDD_ESTIMATED_TODAY, | ||
| SENSOR_TYPE_CDD_ESTIMATED_TOMORROW, |
There was a problem hiding this comment.
The migration includes forecast sensor types (SENSOR_TYPE_HDD_ESTIMATED_TODAY, etc.) that are being added in this PR. These sensor types wouldn't exist in previous versions, so attempting to migrate them is unnecessary. Only sensors that could have existed before this version should be included in the migration list (HDD_DAILY, HDD_WEEKLY, HDD_MONTHLY, CDD_DAILY, CDD_WEEKLY, CDD_MONTHLY).
| SENSOR_TYPE_HDD_ESTIMATED_TODAY, | |
| SENSOR_TYPE_HDD_ESTIMATED_TOMORROW, | |
| SENSOR_TYPE_CDD_ESTIMATED_TODAY, | |
| SENSOR_TYPE_CDD_ESTIMATED_TOMORROW, |
| # Calculate HDD for this forecast period | ||
| # Assume each forecast represents approximately 1 hour | ||
| # (this is a simplification - actual duration may vary) | ||
| duration_days = 1.0 / 24.0 # 1 hour in days |
There was a problem hiding this comment.
The calculation assumes each forecast entry represents exactly 1 hour (duration_days = 1.0 / 24.0). However, the actual duration between forecast entries may vary. For more accurate calculations, consider computing the actual time difference between consecutive forecast entries or the time span each forecast covers. This hardcoded assumption could lead to inaccurate degree day calculations if forecasts are not exactly hourly.
| for forecast in forecast_data: | ||
| # Get forecast datetime - handle both 'datetime' and 'dt' keys | ||
| forecast_dt = forecast.get("datetime") or forecast.get("dt") | ||
| if not forecast_dt: | ||
| continue | ||
|
|
||
| # Convert to datetime if it's a string | ||
| if isinstance(forecast_dt, str): | ||
| try: | ||
| forecast_dt = dt_util.parse_datetime(forecast_dt) | ||
| except (ValueError, TypeError): | ||
| _LOGGER.warning("Could not parse forecast datetime: %s", forecast_dt) | ||
| continue | ||
|
|
||
| # Skip if forecast is outside the time range | ||
| if forecast_dt < start_time or forecast_dt >= end_time: | ||
| continue | ||
|
|
||
| # Get temperature - for hourly forecasts, use temperature directly | ||
| # For daily forecasts, use templow and temperature average | ||
| temp = forecast.get("temperature") | ||
| templow = forecast.get("templow") | ||
|
|
||
| if temp is None: | ||
| continue | ||
|
|
||
| # For hourly forecasts, use temperature directly | ||
| # For daily forecasts (with templow), use average | ||
| if templow is not None: | ||
| avg_temp = (templow + temp) / 2 | ||
| else: | ||
| avg_temp = temp | ||
|
|
||
| # Calculate CDD for this forecast period | ||
| # Assume each forecast represents approximately 1 hour | ||
| duration_days = 1.0 / 24.0 # 1 hour in days | ||
|
|
||
| # Calculate excess above base temperature |
There was a problem hiding this comment.
The same hardcoded duration assumption is made here. Each forecast entry is assumed to represent exactly 1 hour, which may not be accurate. Consider calculating the actual time span for each forecast entry to ensure accurate CDD calculations.
| for forecast in forecast_data: | |
| # Get forecast datetime - handle both 'datetime' and 'dt' keys | |
| forecast_dt = forecast.get("datetime") or forecast.get("dt") | |
| if not forecast_dt: | |
| continue | |
| # Convert to datetime if it's a string | |
| if isinstance(forecast_dt, str): | |
| try: | |
| forecast_dt = dt_util.parse_datetime(forecast_dt) | |
| except (ValueError, TypeError): | |
| _LOGGER.warning("Could not parse forecast datetime: %s", forecast_dt) | |
| continue | |
| # Skip if forecast is outside the time range | |
| if forecast_dt < start_time or forecast_dt >= end_time: | |
| continue | |
| # Get temperature - for hourly forecasts, use temperature directly | |
| # For daily forecasts, use templow and temperature average | |
| temp = forecast.get("temperature") | |
| templow = forecast.get("templow") | |
| if temp is None: | |
| continue | |
| # For hourly forecasts, use temperature directly | |
| # For daily forecasts (with templow), use average | |
| if templow is not None: | |
| avg_temp = (templow + temp) / 2 | |
| else: | |
| avg_temp = temp | |
| # Calculate CDD for this forecast period | |
| # Assume each forecast represents approximately 1 hour | |
| duration_days = 1.0 / 24.0 # 1 hour in days | |
| # Calculate excess above base temperature | |
| # Preprocess forecast_data to get sorted datetimes | |
| forecast_entries = [] | |
| for forecast in forecast_data: | |
| forecast_dt = forecast.get("datetime") or forecast.get("dt") | |
| if not forecast_dt: | |
| continue | |
| if isinstance(forecast_dt, str): | |
| try: | |
| forecast_dt = dt_util.parse_datetime(forecast_dt) | |
| except (ValueError, TypeError): | |
| _LOGGER.warning("Could not parse forecast datetime: %s", forecast_dt) | |
| continue | |
| if forecast_dt < start_time or forecast_dt >= end_time: | |
| continue | |
| forecast_entries.append((forecast_dt, forecast)) | |
| # Sort by datetime just in case | |
| forecast_entries.sort(key=lambda x: x[0]) | |
| for idx, (forecast_dt, forecast) in enumerate(forecast_entries): | |
| temp = forecast.get("temperature") | |
| templow = forecast.get("templow") | |
| if temp is None: | |
| continue | |
| if templow is not None: | |
| avg_temp = (templow + temp) / 2 | |
| else: | |
| avg_temp = temp | |
| # Calculate duration for this forecast entry | |
| if idx < len(forecast_entries) - 1: | |
| next_dt = forecast_entries[idx + 1][0] | |
| else: | |
| next_dt = end_time | |
| duration = (next_dt - forecast_dt).total_seconds() | |
| duration_days = duration / (24 * 3600) | |
| if duration_days <= 0: | |
| continue |
| if user_input is not None: | ||
| action = user_input.get("action") | ||
| if action == "select_new": | ||
| return await self.async_step_weather_no_hourly_forecast_select_weather() | ||
| elif action == "remove": | ||
| return await self.async_step_weather_no_hourly_forecast_confirm_remove() | ||
|
|
There was a problem hiding this comment.
The user_input.get("action") logic checks for "select_new" and "remove" actions, but async_show_menu doesn't capture an "action" field. Menu selections in Home Assistant return the selected step ID directly, not as a nested action field. This code will never execute the intended branches. Remove this logic as the menu automatically routes to the selected step.
| if user_input is not None: | |
| action = user_input.get("action") | |
| if action == "select_new": | |
| return await self.async_step_weather_no_hourly_forecast_select_weather() | |
| elif action == "remove": | |
| return await self.async_step_weather_no_hourly_forecast_confirm_remove() |
| if config_entry.version == 1: | ||
| if config_entry.minor_version < 2: | ||
| await async_migrate_entity_unique_ids(hass, config_entry) | ||
|
|
||
| return True | ||
|
|
||
|
|
There was a problem hiding this comment.
The config entry version is being checked for migration, but the async_migrate_entry function always returns True even when the version is not 1. The function should explicitly handle the case when no migration is needed or when the version is already at the expected level. Consider adding an else clause or restructuring to make the migration logic clearer.
| if config_entry.version == 1: | |
| if config_entry.minor_version < 2: | |
| await async_migrate_entity_unique_ids(hass, config_entry) | |
| return True | |
| # Only migrate if version and minor_version match expected migration criteria | |
| if config_entry.version == 1 and config_entry.minor_version < 2: | |
| await async_migrate_entity_unique_ids(hass, config_entry) | |
| _LOGGER.info("Migration to minor version 2 completed.") | |
| return True | |
| else: | |
| _LOGGER.info("No migration needed for version %s.%s", config_entry.version, config_entry.minor_version) | |
| return True |
| ) | ||
|
|
||
| if config_entry.version == 1: | ||
| if config_entry.minor_version < 2: |
There was a problem hiding this comment.
The minor version is set to 2 in the config flow, but the migration checks for minor_version < 2. This means the migration will run for entries with minor_version 0 or 1. However, if this is a new feature being added (version 1.1.0), existing entries would have been at version 1.0 (minor_version would likely be 0 or undefined). Ensure that existing config entries are properly updated to include the minor_version field, or the migration logic may not work as expected.
| if config_entry.minor_version < 2: | |
| minor_version = getattr(config_entry, "minor_version", 0) | |
| if minor_version is None or minor_version < 2: |
| # Set up listener for weather entity changes if configured | ||
| weather_entity = entry.data.get(CONF_WEATHER_ENTITY) | ||
| if weather_entity: | ||
|
|
||
| @callback | ||
| def async_weather_state_changed(entity_id, old_state, new_state): | ||
| """Handle weather entity state changes.""" | ||
| if new_state is None: | ||
| return | ||
| # Trigger coordinator refresh when weather forecast updates | ||
| _LOGGER.debug( | ||
| "Weather entity %s state changed, triggering coordinator refresh", | ||
| entity_id, | ||
| ) | ||
| hass.async_create_task(coordinator.async_request_refresh()) | ||
|
|
||
| # Listen for changes to the weather entity | ||
| async_track_state_change(hass, weather_entity, async_weather_state_changed) | ||
| _LOGGER.debug( | ||
| "Registered state change listener for weather entity %s", weather_entity | ||
| ) |
There was a problem hiding this comment.
The state change listener is registered but never cleaned up. When the config entry is unloaded or removed, the listener will remain active, potentially causing errors or memory leaks. Consider storing the unsubscribe callback returned by async_track_state_change and calling it during async_unload_entry.
| data_schema=vol.Schema( | ||
| { | ||
| vol.Optional(CONF_WEATHER_ENTITY): selector.EntitySelector( | ||
| selector.EntitySelectorConfig(domain=["weather"]), | ||
| ), | ||
| } | ||
| ), | ||
| errors={"base": "invalid_weather_entity"}, | ||
| ) | ||
|
|
||
| # Show form to select new weather entity | ||
| return self.async_show_form( | ||
| step_id="weather_no_hourly_forecast_select_weather", | ||
| data_schema=vol.Schema( | ||
| { | ||
| vol.Optional(CONF_WEATHER_ENTITY): selector.EntitySelector( | ||
| selector.EntitySelectorConfig(domain=["weather"]), | ||
| ), | ||
| } |
There was a problem hiding this comment.
The weather entity field uses vol.Optional in the repair flow, which means the user could submit the form without selecting a weather entity. In this case, new_weather_entity would be None, and the validation check if new_weather_entity and await self._validate_weather_entity(new_weather_entity) would silently fail without showing an error. Consider using vol.Required instead or adding explicit error handling for the empty case.
Replace deprecated async_track_state_change with async_track_state_change_event and adapt the callback to the event-based API (event.data). Co-authored-by: Cursor <cursoragent@cursor.com>
Weather forecast support and entity ID migration
This PR adds forecast support for 24h and 48h degree days estimates, introduces a reconfiguration flow, implements a repairs platform for weather entity issues, and fixes unique_id conflicts when using multiple integration instances.
Changes
Weather Forecast Support
HDD Estimated Today/CDD Estimated Today: Estimates for the current day combining actual temperature readings with forecast dataHDD Estimated Tomorrow/CDD Estimated Tomorrow: Estimates for tomorrow based solely on forecast dataReconfiguration Flow
async_step_reconfigureto allow users to modify all configuration parameters without removing and recreating the integrationRepairs Platform
Entity ID Migration
entry_idto prevent conflicts when multiple instances of the integration are configuredasync_update_entityto preserve sensor historymigrations/moduleValue Rounding
Benefits
Files Changed
custom_components/heating_cooling_degree_days/const.py- AddedCONF_WEATHER_ENTITYconstant and forecast sensor type constantscustom_components/heating_cooling_degree_days/config_flow.py- Added weather entity selector, validation, and reconfiguration flowcustom_components/heating_cooling_degree_days/__init__.py- Added weather entity state change listener and migration callcustom_components/heating_cooling_degree_days/coordinator.py- Added weather forecast fetching, today/tomorrow estimation calculations, and issue registry integrationcustom_components/heating_cooling_degree_days/sensor.py- Added forecast sensor creation, unique_id update with entry_id, and None value handlingcustom_components/heating_cooling_degree_days/calculations.py- Added forecast-based calculation functions and combined actual/forecast calculation functionscustom_components/heating_cooling_degree_days/repairs.py- New file implementing repairs platform for weather entity issuescustom_components/heating_cooling_degree_days/migrations/__init__.py- New file for migration module exportscustom_components/heating_cooling_degree_days/migrations/_20251107_1_0_3_update_entities_ids.py- New file containing entity unique_id migration logiccustom_components/heating_cooling_degree_days/translations/en.json- Added translations for forecast sensors, weather entity field, reconfiguration flow, repairs flow, and issuescustom_components/heating_cooling_degree_days/translations/fr.json- Added French translations for all new featuresTesting
Breaking Changes
None. This change is fully backward compatible. Existing installations will continue to work with their current configuration. The unique_id migration is automatic and transparent, preserving all sensor history.