From 3727f1cbe2dd82a0e0c9cd4c128dec6854c52b7f Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 16 Mar 2026 08:53:49 +0100 Subject: [PATCH 1/2] Rename projection interval sensors and explicit friendly names --- src/custom_components/wattplan/sensor.py | 168 +++++++++++++++++- tests/integration/test_integration_runtime.py | 49 +++++ 2 files changed, 211 insertions(+), 6 deletions(-) diff --git a/src/custom_components/wattplan/sensor.py b/src/custom_components/wattplan/sensor.py index 858f293..acc384a 100644 --- a/src/custom_components/wattplan/sensor.py +++ b/src/custom_components/wattplan/sensor.py @@ -17,6 +17,7 @@ from .target_runtime import get_active_battery_target from .const import ( + CONF_HOURS_TO_PLAN, CONF_OPTIONS_COUNT, CONF_SLOT_MINUTES, CONF_SOURCE_EXPORT_PRICE, @@ -57,6 +58,21 @@ "gp": "(G)rid and (P)V", } +ENTRY_FRIENDLY_NAMES: dict[str, str] = { + "status": "Status", + "status_message": "Status Message", + "import_price_status": "Import Price Status", + "export_price_status": "Export Price Status", + "usage_status": "Usage Status", + "usage_forecast": "Usage Forecast", + "pv_status": "PV Status", + "last_run": "Last Run", + "next_run": "Next Run", + "last_run_duration": "Last Run Duration", + "plan_details": "Plan Details", + "plan_details_hourly": "Plan Details Hourly", +} + def _subentry_slug(subentry: Any) -> str: """Return slug for subentry naming.""" @@ -68,6 +84,53 @@ def _entry_slug(config_entry: ConfigEntry) -> str: return slugify(config_entry.title) or "entry" +def _subentry_display_name(subentry: Any) -> str: + """Return configured subentry display name, falling back to title.""" + return str(subentry.data.get(CONF_NAME, subentry.title)) + + +def _duration_label(*, minutes: int) -> str: + """Return a compact duration label for user-facing sensor names.""" + if minutes % 60 == 0: + return f"{minutes // 60}h" + return f"{minutes}m" + + +def _entry_sensor_name( + sensor_key: str, *, slot_minutes: int, hours_to_plan: int +) -> str: + """Return explicit entry-level sensor name.""" + if sensor_key == "projected_cost_savings": + return f"Projected Cost Savings over {_duration_label(minutes=hours_to_plan * 60)}" + if sensor_key == "projected_savings_percentage": + return ( + "Projected Savings Percentage over " + f"{_duration_label(minutes=hours_to_plan * 60)}" + ) + if sensor_key == "projected_cost_savings_this_interval": + return f"Projected Cost Savings over {_duration_label(minutes=slot_minutes)}" + if sensor_key == "projected_savings_percentage_this_interval": + return ( + "Projected Savings Percentage over " + f"{_duration_label(minutes=slot_minutes)}" + ) + return ENTRY_FRIENDLY_NAMES[sensor_key] + + +def _subentry_sensor_name(subentry_name: str, sensor_key: str) -> str: + """Return explicit subentry-level sensor name.""" + if sensor_key == "target": + return f"({subentry_name}) Target" + if sensor_key == "action": + return f"({subentry_name}) Action" + if sensor_key == "next_start_option": + return f"({subentry_name}) Next Start Option" + if sensor_key.startswith("option_") and sensor_key.endswith("_start"): + option_number = sensor_key[len("option_") : -len("_start")] + return f"({subentry_name}) Option {option_number} Start" + raise ValueError(f"Unsupported subentry sensor key: {sensor_key}") + + def _entry_device_info(config_entry: ConfigEntry) -> DeviceInfo: """Return shared device info for all entry entities.""" return DeviceInfo( @@ -101,13 +164,15 @@ def __init__( coordinator: WattPlanCoordinator, *, object_id: str, + friendly_name: str, unique_id: str, device_class: SensorDeviceClass | None = None, ) -> None: """Initialize coordinator-backed sensor.""" super().__init__(coordinator) self._attr_object_id = object_id - self._attr_name = object_id + self._attr_name = friendly_name + self.internal_integration_suggested_object_id = object_id self._attr_unique_id = unique_id self._attr_device_info = _entry_device_info(config_entry) if device_class is not None: @@ -316,11 +381,13 @@ def __init__( subentry_id: str, *, object_id: str, + friendly_name: str, unique_id: str, ) -> None: """Initialize battery target sensor.""" self._attr_object_id = object_id - self._attr_name = object_id + self._attr_name = friendly_name + self.internal_integration_suggested_object_id = object_id self._attr_unique_id = unique_id self._attr_device_info = _entry_device_info(config_entry) self._runtime_data = runtime_data @@ -658,6 +725,8 @@ async def async_setup_entry( ) -> None: """Set up WattPlan sensors for one config entry.""" entry_slug = _entry_slug(config_entry) + slot_minutes = int(config_entry.data[CONF_SLOT_MINUTES]) + hours_to_plan = int(config_entry.data[CONF_HOURS_TO_PLAN]) runtime_data = config_entry.runtime_data coordinator = runtime_data.coordinator @@ -665,12 +734,20 @@ async def async_setup_entry( StatusSensor( config_entry, coordinator, + friendly_name=_entry_sensor_name( + "status", slot_minutes=slot_minutes, hours_to_plan=hours_to_plan + ), object_id=f"{entry_slug}_status", unique_id=f"{config_entry.entry_id}:entry:status", ), StatusMessageSensor( config_entry, coordinator, + friendly_name=_entry_sensor_name( + "status_message", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_status_message", unique_id=f"{config_entry.entry_id}:entry:status_message", ), @@ -678,12 +755,20 @@ async def async_setup_entry( config_entry, coordinator, source_key=CONF_SOURCE_IMPORT_PRICE, + friendly_name=_entry_sensor_name( + "import_price_status", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_import_price_status", unique_id=f"{config_entry.entry_id}:entry:import_price_status", ), LastRunSensor( config_entry, coordinator, + friendly_name=_entry_sensor_name( + "last_run", slot_minutes=slot_minutes, hours_to_plan=hours_to_plan + ), object_id=f"{entry_slug}_last_run", unique_id=f"{config_entry.entry_id}:entry:last_run", device_class=SensorDeviceClass.TIMESTAMP, @@ -691,6 +776,9 @@ async def async_setup_entry( NextRunSensor( config_entry, coordinator, + friendly_name=_entry_sensor_name( + "next_run", slot_minutes=slot_minutes, hours_to_plan=hours_to_plan + ), object_id=f"{entry_slug}_next_run", unique_id=f"{config_entry.entry_id}:entry:next_run", device_class=SensorDeviceClass.TIMESTAMP, @@ -698,6 +786,11 @@ async def async_setup_entry( LastRunDurationSensor( config_entry, coordinator, + friendly_name=_entry_sensor_name( + "last_run_duration", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_last_run_duration", unique_id=f"{config_entry.entry_id}:entry:last_run_duration", ), @@ -707,6 +800,11 @@ async def async_setup_entry( projection_key="projected_savings_cost", aggregate_mode="horizon", use_home_currency=True, + friendly_name=_entry_sensor_name( + "projected_cost_savings", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_projected_cost_savings", unique_id=f"{config_entry.entry_id}:entry:projected_cost_savings", ), @@ -715,6 +813,11 @@ async def async_setup_entry( coordinator, projection_key="projected_savings_pct", aggregate_mode="horizon", + friendly_name=_entry_sensor_name( + "projected_savings_percentage", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_projected_savings_percentage", unique_id=f"{config_entry.entry_id}:entry:projected_savings_percentage", native_unit_of_measurement="%", @@ -725,22 +828,40 @@ async def async_setup_entry( projection_key="projected_savings_cost", aggregate_mode="next_interval", use_home_currency=True, - object_id=f"{entry_slug}_projected_cost_savings_next_interval", - unique_id=f"{config_entry.entry_id}:entry:projected_cost_savings_next_interval", + friendly_name=_entry_sensor_name( + "projected_cost_savings_this_interval", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), + object_id=f"{entry_slug}_projected_cost_savings_this_interval", + unique_id=f"{config_entry.entry_id}:entry:projected_cost_savings_this_interval", ), ProjectionSensor( config_entry, coordinator, projection_key="projected_savings_pct", aggregate_mode="next_interval", - object_id=f"{entry_slug}_projected_savings_percentage_next_interval", - unique_id=f"{config_entry.entry_id}:entry:projected_savings_percentage_next_interval", + friendly_name=_entry_sensor_name( + "projected_savings_percentage_this_interval", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), + object_id=f"{entry_slug}_projected_savings_percentage_this_interval", + unique_id=( + f"{config_entry.entry_id}:entry:" + "projected_savings_percentage_this_interval" + ), native_unit_of_measurement="%", ), PlanDetailsSensor( config_entry, coordinator, details_key="plan_details", + friendly_name=_entry_sensor_name( + "plan_details", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_plan_details", unique_id=f"{config_entry.entry_id}:entry:plan_details", ), @@ -748,6 +869,11 @@ async def async_setup_entry( config_entry, coordinator, details_key="plan_details_hourly", + friendly_name=_entry_sensor_name( + "plan_details_hourly", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_plan_details_hourly", unique_id=f"{config_entry.entry_id}:entry:plan_details_hourly", ), @@ -764,6 +890,11 @@ async def async_setup_entry( config_entry, coordinator, source_key=CONF_SOURCE_USAGE, + friendly_name=_entry_sensor_name( + "usage_status", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_usage_status", unique_id=f"{config_entry.entry_id}:entry:usage_status", ) @@ -777,6 +908,11 @@ async def async_setup_entry( UsageForecastSensor( config_entry, coordinator, + friendly_name=_entry_sensor_name( + "usage_forecast", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_usage_forecast", unique_id=f"{config_entry.entry_id}:entry:usage_forecast", ) @@ -793,6 +929,11 @@ async def async_setup_entry( config_entry, coordinator, source_key=CONF_SOURCE_EXPORT_PRICE, + friendly_name=_entry_sensor_name( + "export_price_status", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_export_price_status", unique_id=f"{config_entry.entry_id}:entry:export_price_status", ) @@ -809,6 +950,11 @@ async def async_setup_entry( config_entry, coordinator, source_key=CONF_SOURCE_PV, + friendly_name=_entry_sensor_name( + "pv_status", + slot_minutes=slot_minutes, + hours_to_plan=hours_to_plan, + ), object_id=f"{entry_slug}_pv_status", unique_id=f"{config_entry.entry_id}:entry:pv_status", ) @@ -816,6 +962,7 @@ async def async_setup_entry( for subentry in config_entry.subentries.values(): sub_slug = _subentry_slug(subentry) + subentry_name = _subentry_display_name(subentry) if subentry.subentry_type == SUBENTRY_TYPE_BATTERY: sensors.extend( [ @@ -825,6 +972,7 @@ async def async_setup_entry( config_entry, runtime_data, subentry.subentry_id, + friendly_name=_subentry_sensor_name(subentry_name, "target"), object_id=f"{entry_slug}_{sub_slug}_target", unique_id=f"{config_entry.entry_id}:{subentry.subentry_id}:target", ), @@ -833,6 +981,7 @@ async def async_setup_entry( coordinator, subentry_id=subentry.subentry_id, group="batteries", + friendly_name=_subentry_sensor_name(subentry_name, "action"), object_id=f"{entry_slug}_{sub_slug}_action", unique_id=f"{config_entry.entry_id}:{subentry.subentry_id}:action", ), @@ -846,6 +995,7 @@ async def async_setup_entry( coordinator, subentry_id=subentry.subentry_id, group="comforts", + friendly_name=_subentry_sensor_name(subentry_name, "action"), object_id=f"{entry_slug}_{sub_slug}_action", unique_id=f"{config_entry.entry_id}:{subentry.subentry_id}:action", ), @@ -861,6 +1011,9 @@ async def async_setup_entry( coordinator, subentry_id=subentry.subentry_id, key="next_start_option", + friendly_name=_subentry_sensor_name( + subentry_name, "next_start_option" + ), object_id=f"{entry_slug}_{sub_slug}_next_start_option", unique_id=( f"{config_entry.entry_id}:{subentry.subentry_id}:" @@ -877,6 +1030,9 @@ async def async_setup_entry( coordinator, subentry_id=subentry.subentry_id, key=option_key, + friendly_name=_subentry_sensor_name( + subentry_name, option_key + ), object_id=f"{entry_slug}_{sub_slug}_option_{option_index}_start", unique_id=( f"{config_entry.entry_id}:{subentry.subentry_id}:" diff --git a/tests/integration/test_integration_runtime.py b/tests/integration/test_integration_runtime.py index 7485754..9e834f5 100644 --- a/tests/integration/test_integration_runtime.py +++ b/tests/integration/test_integration_runtime.py @@ -54,6 +54,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util @@ -314,6 +315,28 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: _assert_valid_state(hass, "sensor.home_optional_next_start_option") _assert_valid_state(hass, "sensor.home_optional_option_1_start") + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get("sensor.home_projected_cost_savings_this_interval") + is not None + ) + assert ( + entity_registry.async_get( + "sensor.home_projected_savings_percentage_this_interval" + ) + is not None + ) + assert ( + entity_registry.async_get("sensor.home_projected_cost_savings_next_interval") + is None + ) + assert ( + entity_registry.async_get( + "sensor.home_projected_savings_percentage_next_interval" + ) + is None + ) + next_option = hass.states.get("sensor.home_optional_next_start_option") assert next_option is not None assert next_option.attributes["end_timestamp"] == "2026-01-01T02:00:00+00:00" @@ -325,6 +348,7 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: savings = hass.states.get("sensor.home_projected_cost_savings") assert savings is not None assert float(savings.state) == 3.0 + assert savings.attributes["friendly_name"] == "Projected Cost Savings over 4h" assert "span_start" in savings.attributes assert "span_end" in savings.attributes assert savings.attributes["total"] == 3.0 @@ -337,14 +361,39 @@ async def test_full_runtime_optimize_and_emit_once(hass: HomeAssistant) -> None: assert savings_pct.attributes["span_end"] == savings.attributes["span_end"] assert savings_pct.attributes["total"] == 24.0 assert savings_pct.attributes["values"] == [25.0, 33.333333, 25.0, 14.285714] + assert ( + savings_pct.attributes["friendly_name"] + == "Projected Savings Percentage over 4h" + ) + + projected_cost_entry = entity_registry.async_get( + "sensor.home_projected_cost_savings_this_interval" + ) + assert projected_cost_entry is not None + assert projected_cost_entry.original_name == "Projected Cost Savings over 1h" + + projected_pct_entry = entity_registry.async_get( + "sensor.home_projected_savings_percentage_this_interval" + ) + assert projected_pct_entry is not None + assert projected_pct_entry.original_name == "Projected Savings Percentage over 1h" battery_action = hass.states.get("sensor.home_battery_action") assert battery_action is not None + assert battery_action.attributes["friendly_name"] == "(battery) Action" assert battery_action.attributes["charge_source"] == "g" assert battery_action.attributes["charge_source_friendly"] == "(G)rid" assert battery_action.attributes["next_action"] == "hold" assert "next_action_timestamp" in battery_action.attributes + next_option = hass.states.get("sensor.home_optional_next_start_option") + assert next_option is not None + assert next_option.attributes["friendly_name"] == "(optional) Next Start Option" + + option_1 = hass.states.get("sensor.home_optional_option_1_start") + assert option_1 is not None + assert option_1.attributes["friendly_name"] == "(optional) Option 1 Start" + async def test_battery_action_sensor_exposes_friendly_combined_charge_source( hass: HomeAssistant, From 53bb831c6245fa02b98d1e4c83ad61448b6730da Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 16 Mar 2026 09:03:20 +0100 Subject: [PATCH 2/2] Name next-action sensors explicitly --- src/custom_components/wattplan/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/custom_components/wattplan/sensor.py b/src/custom_components/wattplan/sensor.py index d57ae01..494bf23 100644 --- a/src/custom_components/wattplan/sensor.py +++ b/src/custom_components/wattplan/sensor.py @@ -123,6 +123,8 @@ def _subentry_sensor_name(subentry_name: str, sensor_key: str) -> str: return f"({subentry_name}) Target" if sensor_key == "action": return f"({subentry_name}) Action" + if sensor_key == "next_action": + return f"({subentry_name}) Next Action" if sensor_key == "next_start_option": return f"({subentry_name}) Next Start Option" if sensor_key.startswith("option_") and sensor_key.endswith("_start"): @@ -1039,6 +1041,9 @@ async def async_setup_entry( coordinator, subentry_id=subentry.subentry_id, group="batteries", + friendly_name=_subentry_sensor_name( + subentry_name, "next_action" + ), object_id=f"{entry_slug}_{sub_slug}_next_action", unique_id=( f"{config_entry.entry_id}:{subentry.subentry_id}:next_action" @@ -1063,6 +1068,9 @@ async def async_setup_entry( coordinator, subentry_id=subentry.subentry_id, group="comforts", + friendly_name=_subentry_sensor_name( + subentry_name, "next_action" + ), object_id=f"{entry_slug}_{sub_slug}_next_action", unique_id=( f"{config_entry.entry_id}:{subentry.subentry_id}:next_action"