Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion custom_components/span_panel/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"requirements": [
"span-panel-api~=1.1.14"
],
"version": "1.3.0",
"version": "1.3.1",
"zeroconf": [
{
"type": "_span._tcp.local."
Expand Down
47 changes: 33 additions & 14 deletions custom_components/span_panel/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
51 changes: 43 additions & 8 deletions custom_components/span_panel/sensors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"):
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 36 additions & 14 deletions custom_components/span_panel/sensors/circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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."""
Expand Down Expand Up @@ -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}"
Expand All @@ -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."""
Expand Down
15 changes: 15 additions & 0 deletions custom_components/span_panel/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading