From 66ad36ba13d39a1d872a6d45cf9f968a16da4a22 Mon Sep 17 00:00:00 2001 From: cayossarian Date: Wed, 21 Jan 2026 13:23:36 -0800 Subject: [PATCH 1/3] Fix cleanup_energy_spikes service not finding legacy sensor names (#160) - Use entity registry to find sensors by config entry instead of entity ID prefix - Correctly handles all naming patterns including legacy names without span_panel_ prefix - Add optional main_meter_entity_id parameter for manual sensor selection - Include available_sensors list in error responses for better debugging Closes #160 --- custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/select.py | 9 +- custom_components/span_panel/sensors/base.py | 13 +- custom_components/span_panel/services.yaml | 15 ++ .../services/cleanup_energy_spikes.py | 89 ++++++-- custom_components/span_panel/switch.py | 9 +- tests/test_cleanup_energy_spikes.py | 209 ++++++++++++++++-- 7 files changed, 290 insertions(+), 56 deletions(-) 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..12f6ca5 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.""" @@ -131,9 +134,9 @@ def __init__( 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 @@ -213,7 +216,7 @@ def _handle_coordinator_update(self) -> None: current_circuit_name = circuit.name # Only request reload if the circuit name has actually changed - if self._previous_circuit_name is None: + if 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", diff --git a/custom_components/span_panel/sensors/base.py b/custom_components/span_panel/sensors/base.py index d8a9507..8c5e783 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 @@ -191,7 +194,7 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - if self._previous_circuit_name is None: + if 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 +523,7 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - if self._previous_circuit_name is None: + if 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/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..5346922 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.""" @@ -88,9 +91,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 @@ -112,7 +115,7 @@ def _handle_coordinator_update(self) -> None: current_circuit_name = circuit.name # Only request reload if the circuit name has actually changed - if self._previous_circuit_name is None: + if 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", 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( From f0061b4da781888665b835f70d7a0b032d569c94 Mon Sep 17 00:00:00 2001 From: cayossarian Date: Wed, 21 Jan 2026 13:24:01 -0800 Subject: [PATCH 2/3] Fix reload loop when circuit name is None (#162) - Use sentinel value to distinguish 'never synced' from 'circuit name is None' - Set entity name to None when circuit.name is None to let HA use default behavior - Respect user-customized entity names by checking HA entity registry - Skip sync for entities with user overrides while still tracking panel names This fixes the infinite reload loop and entity flickering when SPAN panel API returns None for circuit names. Also preserves user customizations. Closes #162 --- custom_components/span_panel/select.py | 40 ++++++++++----- custom_components/span_panel/sensors/base.py | 42 ++++++++++++++-- .../span_panel/sensors/circuit.py | 50 +++++++++++++------ custom_components/span_panel/switch.py | 40 ++++++++++----- 4 files changed, 129 insertions(+), 43 deletions(-) diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 12f6ca5..11a32f8 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -108,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) @@ -123,11 +126,14 @@ 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) @@ -215,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 _NAME_UNSET: + # 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", @@ -227,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 8c5e783..c53d44e 100644 --- a/custom_components/span_panel/sensors/base.py +++ b/custom_components/span_panel/sensors/base.py @@ -154,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. @@ -164,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 @@ -194,7 +194,23 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - if self._previous_circuit_name is _NAME_UNSET: + # 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", @@ -523,7 +539,23 @@ def _handle_coordinator_update(self) -> None: if circuit: current_circuit_name = circuit.name - if self._previous_circuit_name is _NAME_UNSET: + # 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/switch.py b/custom_components/span_panel/switch.py index 5346922..cb78811 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -62,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) @@ -77,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) @@ -114,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 _NAME_UNSET: + # 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", @@ -126,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", From 040a1c13f4128190c364a0a303b3760fcf13d7a4 Mon Sep 17 00:00:00 2001 From: cayossarian Date: Wed, 21 Jan 2026 13:58:57 -0800 Subject: [PATCH 3/3] update changelog for 1.3.1 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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