diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a584a0..6191ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.1] - 2026-01-19 + +### 🐛 Bug Fixes + +- **Fix reload loop when circuit name is None (#162)**: Fixed infinite reload loop that caused entity flickering when the SPAN panel + API returns None for circuit names. Uses sentinel value to distinguish between "never synced" and "circuit name is None" states. + When circuit name is None, entity name is set to None allowing HA to use default naming behavior. + Thanks to @NickBorgers for reporting and correctly analyzing a solution. @cayossarian. + +- **Fix spike cleanup service not finding legacy sensor names (#160)**: The `cleanup_energy_spikes` service now correctly finds sensors + regardless of naming pattern (friendly names, circuit numbers, or legacy names without `span_panel_` prefix). + Also adds optional `main_meter_entity_id` parameter allowing users to manually specify the + spike detection sensor when auto-detection of main meter fails or that sensor has been renamed. + Thanks to @mepoland for reporting. @cayossarian. + +### 🔧 Improvements + +- **Respect user-customized entity names**: When a user has customized an entity's friendly name in Home Assistant, + the integration skips name sync for that entity. @cayossarian + ## [1.30] - 2025-12-31 ### 🔄 Changed diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index b62a70f..bff2ab9 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -14,7 +14,7 @@ "requirements": [ "span-panel-api~=1.1.14" ], - "version": "1.3.0", + "version": "1.3.1", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index be4e913..11a32f8 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -33,6 +33,9 @@ _LOGGER = logging.getLogger(__name__) +# Sentinel value to distinguish "never synced" from "circuit name is None" +_NAME_UNSET: object = object() + class SpanPanelSelectEntityDescriptionWrapper: """Wrapper class for Span Panel Select entities.""" @@ -105,8 +108,11 @@ def __init__( if existing_entity_id: # Entity exists - always use panel name for sync - circuit_identifier = circuit.name - self._attr_name = f"{circuit_identifier} {description.entity_description.name}" + # Return None when panel name is None to let HA use default behavior + if circuit.name is None: + self._attr_name = None + else: + self._attr_name = f"{circuit.name} {description.entity_description.name}" else: # Initial install - use flag-based name for entity_id generation use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) @@ -120,20 +126,23 @@ def __init__( circuit_identifier = f"Circuit {circuit.tabs[0]}" else: circuit_identifier = f"Circuit {circuit_id}" + self._attr_name = f"{circuit_identifier} {description.entity_description.name}" else: # Use friendly name format: "Kitchen Outlets Priority" - circuit_identifier = name - - self._attr_name = f"{circuit_identifier} {description.entity_description.name}" + # Return None when panel name is None to let HA use default behavior + if name is None: + self._attr_name = None + else: + self._attr_name = f"{name} {description.entity_description.name}" circuit = self._get_circuit() self._attr_options = description.options_fn(circuit) self._attr_current_option = description.current_option_fn(circuit) # Store initial circuit name for change detection in auto-sync of names - # Only set to None if entity doesn't exist in registry (true first time) + # Use sentinel to distinguish "never synced" from "circuit name is None" if not existing_entity_id: - self._previous_circuit_name = None + self._previous_circuit_name: str | None | object = _NAME_UNSET _LOGGER.info("Select entity not in registry, will sync on first update") else: self._previous_circuit_name = circuit.name @@ -212,8 +221,23 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - # Only request reload if the circuit name has actually changed - if self._previous_circuit_name is None: + # Check if user has customized the name in HA registry + # If so, skip sync - user's customization takes precedence + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, + ) + + if user_has_override: + # Track panel name for future comparisons but don't trigger reload + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: # First update - sync to panel name _LOGGER.info( "First update: syncing entity name to panel name '%s' for select, requesting reload", @@ -224,11 +248,6 @@ def _handle_coordinator_update(self) -> None: # Request integration reload to persist name change self.coordinator.request_reload() elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Name change detected: previous='%s', current='%s' for select", - self._previous_circuit_name, - current_circuit_name, - ) _LOGGER.info( "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", self._previous_circuit_name, diff --git a/custom_components/span_panel/sensors/base.py b/custom_components/span_panel/sensors/base.py index d8a9507..c53d44e 100644 --- a/custom_components/span_panel/sensors/base.py +++ b/custom_components/span_panel/sensors/base.py @@ -31,6 +31,9 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +# Sentinel value to distinguish "never synced" from "circuit name is None" +_NAME_UNSET: object = object() + T = TypeVar("T", bound=SensorEntityDescription) D = TypeVar("D") # For the type returned by get_data_source @@ -114,14 +117,14 @@ def __init__( self._attr_entity_registry_visible_default = description.entity_registry_visible_default # Initialize name sync tracking - # Only set to None if entity doesn't exist in registry (true first time) + # Use sentinel to distinguish "never synced" from "circuit name is None" if span_panel.status.serial_number and description.key and self._attr_unique_id: entity_registry = er.async_get(data_coordinator.hass) existing_entity_id = entity_registry.async_get_entity_id( "sensor", DOMAIN, self._attr_unique_id ) if not existing_entity_id: - self._previous_circuit_name = None + self._previous_circuit_name: str | None | object = _NAME_UNSET else: # Entity exists, get current circuit name for comparison if hasattr(self, "circuit_id"): @@ -130,7 +133,7 @@ def __init__( else: self._previous_circuit_name = None else: - self._previous_circuit_name = None + self._previous_circuit_name = _NAME_UNSET # Use standard coordinator pattern - entities will update automatically # when coordinator data changes @@ -151,7 +154,7 @@ def _generate_unique_id(self, span_panel: SpanPanel, description: T) -> str: """ @abstractmethod - def _generate_friendly_name(self, span_panel: SpanPanel, description: T) -> str: + def _generate_friendly_name(self, span_panel: SpanPanel, description: T) -> str | None: """Generate friendly name for the sensor. Subclasses must implement this to define their naming strategy. @@ -161,11 +164,11 @@ def _generate_friendly_name(self, span_panel: SpanPanel, description: T) -> str: description: The sensor description Returns: - Friendly name string + Friendly name string, or None to let HA use default behavior """ - def _generate_panel_name(self, span_panel: SpanPanel, description: T) -> str: + def _generate_panel_name(self, span_panel: SpanPanel, description: T) -> str | None: """Generate panel name for the sensor (always uses panel circuit name). This method is used for name sync - it always uses the panel circuit name @@ -191,7 +194,23 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - if self._previous_circuit_name is None: + # Check if user has customized the name in HA registry + # If so, skip sync - user's customization takes precedence + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, + ) + + if user_has_override: + # Track panel name for future comparisons but don't trigger reload + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: # First update - sync to panel name _LOGGER.info( "First update: syncing sensor name to panel name '%s', requesting reload", @@ -520,7 +539,23 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - if self._previous_circuit_name is None: + # Check if user has customized the name in HA registry + # If so, skip sync - user's customization takes precedence + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, + ) + + if user_has_override: + # Track panel name for future comparisons but don't trigger reload + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: # First update - sync to panel name _LOGGER.info( "First update: syncing energy sensor name to panel name '%s', requesting reload", diff --git a/custom_components/span_panel/sensors/circuit.py b/custom_components/span_panel/sensors/circuit.py index ecc5c1e..bb1c3dc 100644 --- a/custom_components/span_panel/sensors/circuit.py +++ b/custom_components/span_panel/sensors/circuit.py @@ -65,8 +65,11 @@ def _generate_unique_id( def _generate_friendly_name( self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate friendly name for circuit power sensors based on user preferences.""" + ) -> str | None: + """Generate friendly name for circuit power sensors based on user preferences. + + Returns None when circuit.name is None to let HA use default naming behavior. + """ circuit = span_panel.circuits.get(self.circuit_id) if not circuit: return construct_unmapped_friendly_name( @@ -90,23 +93,31 @@ def _generate_friendly_name( circuit_identifier = f"Circuit {self.circuit_id}" else: # Use friendly name format: "Kitchen Outlets Power" + # Return None when panel name is None to let HA use default behavior + if circuit.name is None: + return None circuit_identifier = circuit.name return f"{circuit_identifier} {description.name or 'Sensor'}" def _generate_panel_name( self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate panel name for circuit sensors (always uses panel circuit name).""" + ) -> str | None: + """Generate panel name for circuit sensors (always uses panel circuit name). + + Returns None when circuit.name is None to let HA use default naming behavior. + """ circuit = span_panel.circuits.get(self.circuit_id) if not circuit: return construct_unmapped_friendly_name( self.circuit_id, str(description.name or "Sensor") ) - # Always use panel name for sync - circuit_identifier = circuit.name - return f"{circuit_identifier} {description.name or 'Sensor'}" + # Return None when panel name is None to let HA use default behavior + if circuit.name is None: + return None + + return f"{circuit.name} {description.name or 'Sensor'}" def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: """Get the data source for the circuit power sensor.""" @@ -197,8 +208,11 @@ def _generate_unique_id( def _generate_friendly_name( self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate friendly name for circuit energy sensors based on user preferences.""" + ) -> str | None: + """Generate friendly name for circuit energy sensors based on user preferences. + + Returns None when circuit.name is None to let HA use default naming behavior. + """ circuit = span_panel.circuits.get(self.circuit_id) if not circuit: return f"Circuit {self.circuit_id} {description.name}" @@ -220,21 +234,29 @@ def _generate_friendly_name( circuit_identifier = f"Circuit {self.circuit_id}" else: # Use friendly name format: "Kitchen Outlets Power" + # Return None when panel name is None to let HA use default behavior + if circuit.name is None: + return None circuit_identifier = circuit.name return f"{circuit_identifier} {description.name}" def _generate_panel_name( self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription - ) -> str: - """Generate panel name for circuit energy sensors (always uses panel circuit name).""" + ) -> str | None: + """Generate panel name for circuit energy sensors (always uses panel circuit name). + + Returns None when circuit.name is None to let HA use default naming behavior. + """ circuit = span_panel.circuits.get(self.circuit_id) if not circuit: return f"Circuit {self.circuit_id} {description.name}" - # Always use panel name for sync - circuit_identifier = circuit.name - return f"{circuit_identifier} {description.name}" + # Return None when panel name is None to let HA use default behavior + if circuit.name is None: + return None + + return f"{circuit.name} {description.name}" def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: """Get the data source for the circuit energy sensor.""" diff --git a/custom_components/span_panel/services.yaml b/custom_components/span_panel/services.yaml index 634349b..1827300 100644 --- a/custom_components/span_panel/services.yaml +++ b/custom_components/span_panel/services.yaml @@ -58,6 +58,21 @@ cleanup_energy_spikes: default: true selector: boolean: + main_meter_entity_id: + name: Main Meter Sensor (Optional) + description: > + The main meter energy sensor to use for spike detection. Must be a consumed + or produced energy sensor (not net/calculated sensors). If not specified, + auto-detects the main meter. Use if you have renamed sensors or auto-detection + fails. Note: The dropdown shows all energy sensors but only consumed/produced + sensors with TOTAL_INCREASING state class are valid. If an invalid sensor is + selected, the error response will list valid sensors. + required: false + selector: + entity: + integration: span_panel + domain: sensor + device_class: energy undo_stats_adjustments: name: Undo Statistics Adjustments diff --git a/custom_components/span_panel/services/cleanup_energy_spikes.py b/custom_components/span_panel/services/cleanup_energy_spikes.py index e334b24..dda4417 100644 --- a/custom_components/span_panel/services/cleanup_energy_spikes.py +++ b/custom_components/span_panel/services/cleanup_energy_spikes.py @@ -41,6 +41,7 @@ vol.Required("start_time"): cv.datetime, vol.Required("end_time"): cv.datetime, vol.Optional("dry_run", default=True): cv.boolean, + vol.Optional("main_meter_entity_id"): cv.entity_id, } ) @@ -73,12 +74,14 @@ async def handle_cleanup_energy_spikes(call: ServiceCall) -> dict[str, Any]: start_time = call.data["start_time"] end_time = call.data["end_time"] dry_run = call.data.get("dry_run", True) + main_meter_entity_id = call.data.get("main_meter_entity_id") _LOGGER.info( - "Parsed values: config_entry_id=%s, start_time=%s, end_time=%s, dry_run=%s", + "Parsed values: config_entry_id=%s, start_time=%s, end_time=%s, dry_run=%s, main_meter_entity_id=%s", config_entry_id, start_time, end_time, dry_run, + main_meter_entity_id, ) return await cleanup_energy_spikes( @@ -87,6 +90,7 @@ async def handle_cleanup_energy_spikes(call: ServiceCall) -> dict[str, Any]: start_time=start_time, end_time=end_time, dry_run=dry_run, + main_meter_entity_id=main_meter_entity_id, ) try: @@ -116,6 +120,7 @@ async def cleanup_energy_spikes( start_time: datetime, end_time: datetime, dry_run: bool = True, + main_meter_entity_id: str | None = None, ) -> dict[str, Any]: """Detect and remove firmware reset spikes from a specific SPAN panel's energy sensors. @@ -128,17 +133,20 @@ async def cleanup_energy_spikes( start_time: Local start time for the time range to scan end_time: Local end time for the time range to scan dry_run: Preview mode without making changes (default: True) + main_meter_entity_id: Optional entity ID of the main meter sensor to use. + If not provided, auto-detects the main meter from the panel's sensors. Returns: Summary of spikes found and removed for the specified panel. """ _LOGGER.info( - "SERVICE CALLED: cleanup_energy_spikes - config_entry_id: %s, start_time: %s, end_time: %s, dry_run: %s", + "SERVICE CALLED: cleanup_energy_spikes - config_entry_id: %s, start_time: %s, end_time: %s, dry_run: %s, main_meter_entity_id: %s", config_entry_id, start_time, end_time, dry_run, + main_meter_entity_id, ) # Validate config entry exists and is a SPAN panel @@ -233,24 +241,49 @@ async def cleanup_energy_spikes( ) # Find the main meter for this specific panel - main_meter_entity = _find_main_meter_sensor(entry_sensors) - - if not main_meter_entity: - _LOGGER.warning( - "No main meter found for config entry %s", - config_entry_id, - ) - return { - "dry_run": dry_run, - "config_entry_id": config_entry_id, - "entities_processed": len(entry_sensors), - "reset_timestamps": [], - "sensors_adjusted": 0, - "details": [], - "error": f"No main meter sensor found for config entry {config_entry_id}", - } - - _LOGGER.debug("Using main meter sensor: %s", main_meter_entity) + # Use provided entity ID if available, otherwise auto-detect + if main_meter_entity_id: + # Validate provided entity exists and belongs to this config entry + if main_meter_entity_id not in entry_sensors: + _LOGGER.warning( + "Provided main_meter_entity_id '%s' is not a SPAN energy sensor for config entry %s. " + "Available sensors: %s", + main_meter_entity_id, + config_entry_id, + entry_sensors, + ) + return { + "dry_run": dry_run, + "config_entry_id": config_entry_id, + "entities_processed": len(entry_sensors), + "reset_timestamps": [], + "sensors_adjusted": 0, + "details": [], + "available_sensors": sorted(entry_sensors), + "error": f"Provided sensor '{main_meter_entity_id}' is not a SPAN energy sensor for this panel", + } + main_meter_entity: str | None = main_meter_entity_id + _LOGGER.info("Using user-provided main meter sensor: %s", main_meter_entity) + else: + main_meter_entity = _find_main_meter_sensor(entry_sensors) + if not main_meter_entity: + _LOGGER.warning( + "No main meter auto-detected for config entry %s. Available sensors: %s", + config_entry_id, + entry_sensors, + ) + return { + "dry_run": dry_run, + "config_entry_id": config_entry_id, + "entities_processed": len(entry_sensors), + "reset_timestamps": [], + "sensors_adjusted": 0, + "details": [], + "available_sensors": sorted(entry_sensors), + "error": f"No main meter sensor auto-detected for config entry {config_entry_id}. " + f"Please specify main_meter_entity_id from available sensors.", + } + _LOGGER.debug("Auto-detected main meter sensor: %s", main_meter_entity) # Expand query window by 1 hour before start_time for spike detection # This ensures we can detect spikes AT start_time by having the previous entry to compare. @@ -274,6 +307,8 @@ async def cleanup_energy_spikes( ) # Get statistics for this panel's main meter to find reset timestamps + # At this point main_meter_entity is guaranteed to be set (we returned early otherwise) + assert main_meter_entity is not None try: reset_timestamps = await _find_reset_timestamps( hass, main_meter_entity, query_start_time_utc, query_end_time_utc @@ -416,11 +451,21 @@ async def cleanup_energy_spikes( def _get_span_energy_sensors(hass: HomeAssistant) -> list[str]: - """Get all SPAN energy sensors with TOTAL_INCREASING state class.""" + """Get all SPAN energy sensors with TOTAL_INCREASING state class. + + Uses entity registry to find sensors by config entry rather than entity ID prefix. + This correctly handles all naming patterns including legacy names without span_panel_ prefix. + """ span_energy_sensors = [] + registry = er.async_get(hass) + + # Get all config entry IDs for span_panel domain + span_entry_ids = {entry.entry_id for entry in hass.config_entries.async_entries(DOMAIN)} for entity_id in hass.states.async_entity_ids("sensor"): - if not entity_id.startswith("sensor.span_panel_"): + # Check if entity belongs to a span_panel config entry + entry = registry.async_get(entity_id) + if not entry or entry.config_entry_id not in span_entry_ids: continue state = hass.states.get(entity_id) diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 585a687..cb78811 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -29,6 +29,9 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +# Sentinel value to distinguish "never synced" from "circuit name is None" +_NAME_UNSET: object = object() + class SpanPanelCircuitsSwitch(CoordinatorEntity[SpanPanelCoordinator], SwitchEntity): """Represent a switch entity.""" @@ -59,8 +62,11 @@ def __init__( if existing_entity_id: # Entity exists - always use panel name for sync - circuit_identifier = circuit.name - self._attr_name = f"{circuit_identifier} Breaker" + # Return None when panel name is None to let HA use default behavior + if circuit.name is None: + self._attr_name = None + else: + self._attr_name = f"{circuit.name} Breaker" else: # Initial install - use flag-based name for entity_id generation use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) @@ -74,11 +80,14 @@ def __init__( circuit_identifier = f"Circuit {circuit.tabs[0]}" else: circuit_identifier = f"Circuit {circuit_id}" + self._attr_name = f"{circuit_identifier} Breaker" else: # Use friendly name format: "Kitchen Outlets Breaker" - circuit_identifier = name - - self._attr_name = f"{circuit_identifier} Breaker" + # Return None when panel name is None to let HA use default behavior + if name is None: + self._attr_name = None + else: + self._attr_name = f"{name} Breaker" super().__init__(coordinator) @@ -88,9 +97,9 @@ def __init__( # when coordinator data changes # Store initial circuit name for change detection in auto-sync - # Only set to None if entity doesn't exist in registry (true first time) + # Use sentinel to distinguish "never synced" from "circuit name is None" if not existing_entity_id: - self._previous_circuit_name = None + self._previous_circuit_name: str | None | object = _NAME_UNSET _LOGGER.info("Switch entity not in registry, will sync on first update") else: self._previous_circuit_name = circuit.name @@ -111,8 +120,23 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - # Only request reload if the circuit name has actually changed - if self._previous_circuit_name is None: + # Check if user has customized the name in HA registry + # If so, skip sync - user's customization takes precedence + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, + ) + + if user_has_override: + # Track panel name for future comparisons but don't trigger reload + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: # First update - sync to panel name _LOGGER.info( "First update: syncing entity name to panel name '%s' for switch, requesting reload", @@ -123,11 +147,6 @@ def _handle_coordinator_update(self) -> None: # Request integration reload to persist name change self.coordinator.request_reload() elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Name change detected: previous='%s', current='%s' for switch", - self._previous_circuit_name, - current_circuit_name, - ) _LOGGER.info( "Auto-sync detected circuit name change from '%s' to '%s' for " "switch, requesting integration reload", diff --git a/tests/test_cleanup_energy_spikes.py b/tests/test_cleanup_energy_spikes.py index be371f0..38fc80d 100644 --- a/tests/test_cleanup_energy_spikes.py +++ b/tests/test_cleanup_energy_spikes.py @@ -119,14 +119,23 @@ def mock_span_energy_sensors(): class TestGetSpanEnergySensors: """Tests for _get_span_energy_sensors function.""" - def test_finds_total_increasing_sensors(self, mock_hass, mock_span_energy_sensors): + def test_finds_total_increasing_sensors( + self, mock_hass, mock_span_energy_sensors, mock_entity_registry + ): """Test that only TOTAL_INCREASING sensors are returned.""" - mock_hass.states.async_entity_ids.return_value = list( - mock_span_energy_sensors.keys() - ) + # Set up entity registry with SPAN entities + entity_ids = list(mock_span_energy_sensors.keys()) + registry = mock_entity_registry(entity_ids) + + # Set up states + mock_hass.states.async_entity_ids.return_value = entity_ids mock_hass.states.get = lambda entity_id: mock_span_energy_sensors.get(entity_id) - result = _get_span_energy_sensors(mock_hass) + with patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er: + mock_er.return_value = registry + result = _get_span_energy_sensors(mock_hass) # Should find 3 TOTAL_INCREASING sensors (not the power sensor) assert len(result) == 3 @@ -135,31 +144,82 @@ def test_finds_total_increasing_sensors(self, mock_hass, mock_span_energy_sensor assert "sensor.span_panel_kitchen_consumed_energy" in result assert "sensor.span_panel_current_power" not in result - def test_ignores_non_span_sensors(self, mock_hass): - """Test that non-SPAN sensors are ignored.""" + def test_ignores_non_span_sensors(self, mock_hass, mock_entity_registry): + """Test that non-SPAN sensors are ignored (not in span_panel config entry).""" + # Only register the span sensor in the entity registry + registry = mock_entity_registry(["sensor.span_panel_test_energy"]) + + # Create a mock entry for non-span sensor that returns None (not in registry) + original_async_get = registry.async_get + + def mock_async_get_fn(entity_id): + if entity_id == "sensor.some_other_sensor": + return None # Not a SPAN sensor + return original_async_get(entity_id) + + registry.async_get = mock_async_get_fn + + # Set up states for both sensors mock_hass.states.async_entity_ids.return_value = [ "sensor.some_other_sensor", "sensor.span_panel_test_energy", ] - mock_hass.states.get.return_value = State( - "sensor.span_panel_test_energy", + mock_hass.states.get = lambda entity_id: State( + entity_id, "1000", {"state_class": SensorStateClass.TOTAL_INCREASING}, ) - result = _get_span_energy_sensors(mock_hass) + with patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er: + mock_er.return_value = registry + result = _get_span_energy_sensors(mock_hass) + # Only the SPAN sensor should be returned assert len(result) == 1 assert "sensor.span_panel_test_energy" in result - def test_handles_no_sensors(self, mock_hass): + def test_handles_no_sensors(self, mock_hass, mock_entity_registry): """Test handling when no sensors exist.""" + registry = mock_entity_registry([]) mock_hass.states.async_entity_ids.return_value = [] - result = _get_span_energy_sensors(mock_hass) + with patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er: + mock_er.return_value = registry + result = _get_span_energy_sensors(mock_hass) assert result == [] + def test_finds_legacy_sensors_without_span_prefix(self, mock_hass, mock_entity_registry): + """Test that legacy sensors without span_panel_ prefix are found via registry.""" + # Legacy sensors don't have span_panel_ prefix but ARE in the registry + legacy_sensors = [ + "sensor.main_meter_consumed_energy", + "sensor.kitchen_consumed_energy", + ] + registry = mock_entity_registry(legacy_sensors) + + mock_hass.states.async_entity_ids.return_value = legacy_sensors + mock_hass.states.get = lambda entity_id: State( + entity_id, + "5000" if "main_meter" in entity_id else "1000", + {"state_class": SensorStateClass.TOTAL_INCREASING}, + ) + + with patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er: + mock_er.return_value = registry + result = _get_span_energy_sensors(mock_hass) + + # Both legacy sensors should be found via registry lookup + assert len(result) == 2 + assert "sensor.main_meter_consumed_energy" in result + assert "sensor.kitchen_consumed_energy" in result + class TestFindMainMeterSensor: """Tests for _find_main_meter_sensor function.""" @@ -199,6 +259,18 @@ def test_returns_none_when_no_main_meter(self): assert result is None + def test_finds_legacy_main_meter_without_prefix(self): + """Test finding legacy main meter sensor without span_panel_ prefix.""" + # Legacy naming pattern from pre-1.0.4 installations + sensors = [ + "sensor.main_meter_consumed_energy", + "sensor.kitchen_consumed_energy", + ] + + result = _find_main_meter_sensor(sensors) + + assert result == "sensor.main_meter_consumed_energy" + class TestServiceRegistration: """Tests for service registration.""" @@ -245,18 +317,23 @@ async def test_invalid_config_entry(self, mock_hass): assert "not a SPAN panel" in result["error"] @pytest.mark.asyncio - async def test_no_sensors_found(self, mock_hass): + async def test_no_sensors_found(self, mock_hass, mock_entity_registry): """Test handling when no SPAN sensors are found.""" + registry = mock_entity_registry([]) mock_hass.states.async_entity_ids.return_value = [] start_time, end_time = _get_test_time_range() - result = await cleanup_energy_spikes( - mock_hass, - config_entry_id=TEST_CONFIG_ENTRY_ID, - start_time=start_time, - end_time=end_time, - dry_run=True, - ) + with patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er: + mock_er.return_value = registry + result = await cleanup_energy_spikes( + mock_hass, + config_entry_id=TEST_CONFIG_ENTRY_ID, + start_time=start_time, + end_time=end_time, + dry_run=True, + ) assert result["entities_processed"] == 0 assert result["error"] == "No SPAN energy sensors found" @@ -266,6 +343,7 @@ async def test_no_main_meter_found(self, mock_hass, mock_entity_registry): """Test handling when main meter is not found.""" # Create sensor without "main_meter" in name entity_ids = ["sensor.span_panel_kitchen_consumed_energy"] + registry = mock_entity_registry(entity_ids) mock_hass.states.async_entity_ids.return_value = entity_ids mock_hass.states.get.return_value = State( "sensor.span_panel_kitchen_consumed_energy", @@ -276,7 +354,92 @@ async def test_no_main_meter_found(self, mock_hass, mock_entity_registry): with patch( "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" ) as mock_er: - mock_er.return_value = mock_entity_registry(entity_ids) + mock_er.return_value = registry + start_time, end_time = _get_test_time_range() + result = await cleanup_energy_spikes( + mock_hass, + config_entry_id=TEST_CONFIG_ENTRY_ID, + start_time=start_time, + end_time=end_time, + dry_run=True, + ) + + assert "No main meter sensor auto-detected" in result["error"] + # Should include available sensors in response + assert "available_sensors" in result + assert "sensor.span_panel_kitchen_consumed_energy" in result["available_sensors"] + + @pytest.mark.asyncio + async def test_user_provided_main_meter_entity_id( + self, mock_hass, mock_entity_registry + ): + """Test using user-provided main_meter_entity_id parameter.""" + # Create sensors including a renamed main meter + entity_ids = [ + "sensor.my_custom_main_meter", # User renamed this + "sensor.span_panel_kitchen_consumed_energy", + ] + registry = mock_entity_registry(entity_ids) + mock_hass.states.async_entity_ids.return_value = entity_ids + mock_hass.states.get = lambda entity_id: State( + entity_id, + "5000" if "custom_main_meter" in entity_id else "1000", + {"state_class": SensorStateClass.TOTAL_INCREASING}, + ) + + # Mock recorder to return stable statistics (no spikes) + mock_stats = { + "sensor.my_custom_main_meter": [ + {"start": 1733760000, "sum": 5000.0}, + {"start": 1733763600, "sum": 5100.0}, + ] + } + + with ( + patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er, + patch( + "custom_components.span_panel.services.cleanup_energy_spikes.get_instance" + ) as mock_get_instance, + ): + mock_er.return_value = registry + mock_recorder = MagicMock() + mock_recorder.async_add_executor_job = AsyncMock(return_value=mock_stats) + mock_get_instance.return_value = mock_recorder + start_time, end_time = _get_test_time_range() + + result = await cleanup_energy_spikes( + mock_hass, + config_entry_id=TEST_CONFIG_ENTRY_ID, + start_time=start_time, + end_time=end_time, + dry_run=True, + main_meter_entity_id="sensor.my_custom_main_meter", + ) + + # Should succeed using the user-provided entity + assert "error" not in result or result.get("error") is None + assert result["entities_processed"] == 2 + + @pytest.mark.asyncio + async def test_invalid_user_provided_main_meter_entity_id( + self, mock_hass, mock_entity_registry + ): + """Test error when user provides invalid main_meter_entity_id.""" + entity_ids = ["sensor.span_panel_kitchen_consumed_energy"] + registry = mock_entity_registry(entity_ids) + mock_hass.states.async_entity_ids.return_value = entity_ids + mock_hass.states.get.return_value = State( + "sensor.span_panel_kitchen_consumed_energy", + "1000", + {"state_class": SensorStateClass.TOTAL_INCREASING}, + ) + + with patch( + "custom_components.span_panel.services.cleanup_energy_spikes.er.async_get" + ) as mock_er: + mock_er.return_value = registry start_time, end_time = _get_test_time_range() result = await cleanup_energy_spikes( mock_hass, @@ -284,9 +447,11 @@ async def test_no_main_meter_found(self, mock_hass, mock_entity_registry): start_time=start_time, end_time=end_time, dry_run=True, + main_meter_entity_id="sensor.nonexistent_sensor", ) - assert "No main meter sensor found" in result["error"] + assert "not a SPAN energy sensor" in result["error"] + assert "available_sensors" in result @pytest.mark.asyncio async def test_no_spikes_detected(