Skip to content

Forecast support for 24h and 48h degree days estimates#63

Open
alepee wants to merge 6 commits intorelease/1.1.0from
feat/forecast
Open

Forecast support for 24h and 48h degree days estimates#63
alepee wants to merge 6 commits intorelease/1.1.0from
feat/forecast

Conversation

@alepee
Copy link
Owner

@alepee alepee commented Nov 7, 2025

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

  • Optional weather entity configuration: Added support for configuring a weather entity to provide forecast data for degree days estimates
  • New forecast sensors:
    • HDD Estimated Today / CDD Estimated Today: Estimates for the current day combining actual temperature readings with forecast data
    • HDD Estimated Tomorrow / CDD Estimated Tomorrow: Estimates for tomorrow based solely on forecast data
  • Automatic refresh: Forecast sensors automatically update when the weather entity state changes
  • Progressive refinement: Today's estimate refines throughout the day as actual temperature sensor data becomes available

Reconfiguration Flow

  • Update existing integration settings: Added async_step_reconfigure to allow users to modify all configuration parameters without removing and recreating the integration
  • Pre-filled form: The reconfiguration form is pre-filled with current values for better user experience
  • Accessible from integration settings: Users can reconfigure the integration directly from Home Assistant's integration settings

Repairs Platform

  • Automatic issue detection: Detects when the configured weather entity no longer supports hourly forecasts
  • Guided repair flow: Provides a repair flow that allows users to:
    • Select a new compatible weather entity
    • Remove the weather entity (disables forecast sensors)

Entity ID Migration

  • Fixed unique_id conflicts: Unique IDs now include entry_id to prevent conflicts when multiple instances of the integration are configured
  • Automatic migration: Existing entities are automatically migrated to the new format on integration startup
  • History preservation: Migration uses async_update_entity to preserve sensor history
  • Organized migration code: Migration logic is now in a dedicated migrations/ module

Value Rounding

  • Consistent precision: All forecast sensor values are rounded to 1 decimal place for better readability

Benefits

  • Forecast-based planning: Users can now see estimated degree days for today and tomorrow, helping with energy planning
  • Better user experience: Reconfiguration flow eliminates the need to remove and recreate integrations when changing settings
  • Robust error handling: Repairs platform provides clear guidance when weather entity issues occur
  • Multiple instances support: Users can now configure multiple instances of the integration without unique_id conflicts
  • Data preservation: Automatic migration preserves historical data when updating from previous versions

Files Changed

  • custom_components/heating_cooling_degree_days/const.py - Added CONF_WEATHER_ENTITY constant and forecast sensor type constants
  • custom_components/heating_cooling_degree_days/config_flow.py - Added weather entity selector, validation, and reconfiguration flow
  • custom_components/heating_cooling_degree_days/__init__.py - Added weather entity state change listener and migration call
  • custom_components/heating_cooling_degree_days/coordinator.py - Added weather forecast fetching, today/tomorrow estimation calculations, and issue registry integration
  • custom_components/heating_cooling_degree_days/sensor.py - Added forecast sensor creation, unique_id update with entry_id, and None value handling
  • custom_components/heating_cooling_degree_days/calculations.py - Added forecast-based calculation functions and combined actual/forecast calculation functions
  • custom_components/heating_cooling_degree_days/repairs.py - New file implementing repairs platform for weather entity issues
  • custom_components/heating_cooling_degree_days/migrations/__init__.py - New file for migration module exports
  • custom_components/heating_cooling_degree_days/migrations/_20251107_1_0_3_update_entities_ids.py - New file containing entity unique_id migration logic
  • custom_components/heating_cooling_degree_days/translations/en.json - Added translations for forecast sensors, weather entity field, reconfiguration flow, repairs flow, and issues
  • custom_components/heating_cooling_degree_days/translations/fr.json - Added French translations for all new features

Testing

  • Verify forecast sensors are created when a weather entity is configured
  • Verify forecast sensors are not created when no weather entity is configured
  • Verify today's estimate combines actual and forecast data correctly
  • Verify tomorrow's estimate uses only forecast data
  • Verify forecast sensors update when weather entity state changes
  • Verify reconfiguration flow allows updating all settings
  • Verify repairs flow appears when weather entity loses hourly forecast support
  • Verify unique_id migration works correctly (test with existing installation)
  • Verify no unique_id conflicts when configuring multiple integration instances
  • Verify sensor history is preserved after migration
  • Verify forecast sensor values are rounded to 1 decimal place
  • Verify None values are handled gracefully when forecasts are unavailable

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.

… 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.
@alepee alepee self-assigned this Nov 7, 2025
@alepee alepee added the enhancement New feature or request label Nov 7, 2025
- 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.
@alepee alepee changed the base branch from main to release/1.1.0 November 9, 2025 09:10
… 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.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +32 to +33


Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
raise ValueError(f"No repair flow found for issue_id: {issue_id}")

Copilot uses AI. Check for mistakes.
Comment on lines +393 to +412
# 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()
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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")

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +65
SENSOR_TYPE_HDD_ESTIMATED_TODAY,
SENSOR_TYPE_HDD_ESTIMATED_TOMORROW,
SENSOR_TYPE_CDD_ESTIMATED_TODAY,
SENSOR_TYPE_CDD_ESTIMATED_TOMORROW,
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
SENSOR_TYPE_HDD_ESTIMATED_TODAY,
SENSOR_TYPE_HDD_ESTIMATED_TOMORROW,
SENSOR_TYPE_CDD_ESTIMATED_TODAY,
SENSOR_TYPE_CDD_ESTIMATED_TOMORROW,

Copilot uses AI. Check for mistakes.
Comment on lines +372 to +375
# 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
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +421 to +458
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
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +75
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()

Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
Comment on lines +42 to 48
if config_entry.version == 1:
if config_entry.minor_version < 2:
await async_migrate_entity_unique_ids(hass, config_entry)

return True


Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
)

if config_entry.version == 1:
if config_entry.minor_version < 2:
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if config_entry.minor_version < 2:
minor_version = getattr(config_entry, "minor_version", 0)
if minor_version is None or minor_version < 2:

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +129
# 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
)
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +126 to +144
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"]),
),
}
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants