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
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@ The agent must:
- Add or update tests for behavior changes, especially setup flows, coordinator behavior, and entity
state handling.

### Device Topology (issue #875)

HSEM entities are split across **7 devices**, not one: Controller, Battery & Energy, Hourly
Consumption Profile, Financial, Forecast, EV Primary, and EV Secondary. See
`custom_components/hsem/devices.py` for the `HSEMDevice` enum and `DeviceInfo` construction, and
`docs/sensors-reference.md` (Devices section) for the full entity-to-device mapping.

- Every `HSEMEntity` subclass resolves `device_info` via `self._hsem_device` (a class attribute,
or set dynamically per instance for entities that come in primary/secondary EV pairs — see
`entity.py`). New entities MUST set `_hsem_device` explicitly unless they belong on Controller
(the default).
- The Controller device keeps the pre-split `(DOMAIN, entry_id)` identifier; every other device is
`(DOMAIN, f"{entry_id}_<device>")`.
- `unique_id` is the permanent identity key and must **never** change when moving an entity between
devices — only `device_id` (via `entity_registry.async_update_entity`) changes.
- A one-time entity-registry migration (`custom_components/hsem/device_migration.py`), gated by a
migration-version flag on the config entry, reassigns `device_id` for pre-existing entities. It
runs once per config entry and is a no-op on subsequent calls.
- EV Secondary / OCPP entity **names** (not `unique_id`/`entity_id`) drop redundant `"Second"`/`"2"`
markers — the device name already disambiguates them via `_attr_has_entity_name = True`.

## Git Workflow

Branch naming convention:
Expand Down
15 changes: 12 additions & 3 deletions custom_components/hsem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from custom_components.hsem.const import DOMAIN, MIN_HUAWEI_SOLAR_VERSION
from custom_components.hsem.coordinator import HSEMDataUpdateCoordinator
from custom_components.hsem.device_migration import async_migrate_devices
from custom_components.hsem.services import (
async_register_services,
async_unregister_services,
Expand Down Expand Up @@ -185,14 +186,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HSEMConfigEntry) -> bool
"""Set up the HSEM integration from a config entry.

Creates the shared :class:`HSEMDataUpdateCoordinator`, forwards platform
setups, runs the first update cycle, and adds an options update
listener. Services are registered once in ``async_setup``.
setups, runs the first update cycle, runs the one-time device-split
entity-registry migration, and adds an options update listener.
Services are registered once in ``async_setup``.

The first update cycle intentionally runs *after* platform setups are
forwarded: it reads HSEM's own select/number/switch/time entities back
via the live HA state machine, so those entities must already exist
(issue #926) — running it earlier raced the entity registry and logged
spurious "not found" warnings on every restart/reload.
spurious "not found" warnings on every restart/reload. The device-split
migration (issue #875) runs after that for the same reason — it needs
the entity registry already populated — and is internally gated so
subsequent calls are no-ops.
"""
if not await check_huawei_solar_version(hass):
raise ConfigEntryError(
Expand All @@ -214,6 +219,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HSEMConfigEntry) -> bool
# Run the first update cycle now that HSEM's own entities exist.
await _run_coordinator_setup_step(coordinator.async_run_first_refresh())

# One-time entity-registry migration for the device split (issue #875).
# Gated internally so a second call is a no-op.
await async_migrate_devices(hass, entry)

# Add update listener for options.
entry.async_on_unload(entry.add_update_listener(async_update_options))

Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_numbers/battery_efficiency.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant

from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMEntity
from custom_components.hsem.utils.conversion import convert_to_float
from custom_components.hsem.utils.misc import get_config_value
Expand Down Expand Up @@ -63,6 +64,7 @@ def __init__(
entity_id: The desired entity_id string for this entity.
"""
super().__init__(config_entry)
self._hsem_device = HSEMDevice.BATTERY_ENERGY

self.hass = hass
self._config_entry = config_entry
Expand Down
8 changes: 8 additions & 0 deletions custom_components/hsem/custom_numbers/ev_target_soc.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant

from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMEntity
from custom_components.hsem.utils.conversion import convert_to_float
from custom_components.hsem.utils.misc import get_config_value
Expand Down Expand Up @@ -50,6 +51,7 @@ def __init__(
*,
unique_id: str = "",
entity_id: str = "",
is_second: bool = False,
) -> None:
"""Initialize the number entity.

Expand All @@ -61,8 +63,14 @@ def __init__(
default: Default value when no config entry value exists yet.
unique_id: Stable unique ID for HA entity registry.
entity_id: The desired entity_id string for this entity.
is_second: ``True`` when this number targets the second EV,
routing ``device_info`` to the EV Secondary device instead
of EV Primary.
"""
super().__init__(config_entry)
self._hsem_device = (
HSEMDevice.EV_SECONDARY if is_second else HSEMDevice.EV_PRIMARY
)

self.hass = hass
self._config_entry = config_entry
Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_selectors/solcast_likelihood.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant

from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMEntity
from custom_components.hsem.utils.misc import get_config_value
from custom_components.hsem.utils.sensornames.diagnostics import (
Expand Down Expand Up @@ -55,6 +56,7 @@ def __init__(
and ``options``.
"""
super().__init__(config_entry)
self._hsem_device = HSEMDevice.FORECAST

self.hass = hass
self._config_entry = config_entry
Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_sensors/avg_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from homeassistant.helpers.restore_state import RestoreEntity

from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMEntity
from custom_components.hsem.utils.conversion import convert_to_float
from custom_components.hsem.utils.ha_helpers import ha_get_entity_state_and_convert
Expand Down Expand Up @@ -67,6 +68,7 @@ def __init__(
entity_id: Entity ID for the sensor.
"""
super().__init__(config_entry)
self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION
self._hour_start = hour_start
self._hour_end = hour_end
self._average = avg
Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_sensors/battery_soc_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.diagnostics import (
get_battery_soc_sensor_entity_id,
Expand Down Expand Up @@ -77,6 +78,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.BATTERY_ENERGY

self._config_entry = config_entry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.diagnostics import (
get_effective_discharge_floor_sensor_entity_id,
Expand Down Expand Up @@ -59,6 +60,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.BATTERY_ENERGY

self._config_entry = config_entry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.ev import (
get_ev_charger_calculated_power_sensor_entity_id,
Expand Down Expand Up @@ -83,6 +84,9 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = (
HSEMDevice.EV_SECONDARY if self._is_second else HSEMDevice.EV_PRIMARY
)

self._config_entry = config_entry
self._restored_state: str | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.models.hourly_recommendation import HourlyRecommendation
from custom_components.hsem.utils.phase_power import (
Expand Down Expand Up @@ -97,6 +98,9 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = (
HSEMDevice.EV_SECONDARY if self._is_second else HSEMDevice.EV_PRIMARY
)

self._config_entry = config_entry

Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_sensors/ev_charging_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.ev import (
get_ev_charging_sensor_entity_id,
Expand Down Expand Up @@ -73,6 +74,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.EV_PRIMARY

self._config_entry = config_entry

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.ev import (
get_ev_optimal_charging_plan_sensor_entity_id,
Expand Down Expand Up @@ -83,6 +84,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.EV_PRIMARY

self._config_entry = config_entry
self._attr_unique_id = get_ev_optimal_charging_plan_sensor_unique_id(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.ev import (
get_ev_second_optimal_charging_plan_sensor_entity_id,
Expand Down Expand Up @@ -59,6 +60,7 @@ def __init__(
"""Initialise the second EV optimal charging plan sensor."""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.EV_SECONDARY

self._config_entry = config_entry
self._attr_unique_id = get_ev_second_optimal_charging_plan_sensor_unique_id(
Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_sensors/financial_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from custom_components.hsem.coordinator import (
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.models.financial_tracker import FinancialTracker
from custom_components.hsem.utils.sensornames.financial import (
Expand Down Expand Up @@ -79,6 +80,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.FINANCIAL
self._config_entry = config_entry
self._restored_state: str | None = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.datetime_utils import now as hsem_now
from custom_components.hsem.utils.forecast_tracker import ForecastTracker
Expand Down Expand Up @@ -78,6 +79,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.FORECAST
self._attr_unique_id = get_forecast_accuracy_sensor_unique_id(
config_entry.entry_id
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from custom_components.hsem.custom_sensors.utility_meter_sensor import (
HSEMUtilityMeterSensor,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMEntity
from custom_components.hsem.utils.conversion import convert_to_float
from custom_components.hsem.utils.ha_helpers import ha_get_entity_state_and_convert
Expand Down Expand Up @@ -107,6 +108,7 @@ def __init__(
async_add_entities: HA callback to register derived child entities.
"""
super().__init__(config_entry)
self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION
self._available = False
self._missing_input_entities = True
self._hsem_house_consumption_power = None
Expand Down
2 changes: 2 additions & 0 deletions custom_components/hsem/custom_sensors/integration_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry

from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMEntity


Expand Down Expand Up @@ -56,6 +57,7 @@ def __init__(
"config_entry is required for HSEMIntegrationSensor"
)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.HOURLY_CONSUMPTION
self._attr_unique_id = id
self.entity_id = e_id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.utils.sensornames.diagnostics import (
get_net_consumption_sensor_entity_id,
Expand Down Expand Up @@ -78,6 +79,7 @@ def __init__(
"""
HSEMCoordinatorEntity.__init__(self, coordinator)
HSEMEntity.__init__(self, config_entry)
self._hsem_device = HSEMDevice.BATTERY_ENERGY

self._config_entry = config_entry

Expand Down
13 changes: 13 additions & 0 deletions custom_components/hsem/custom_sensors/ocpp_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
CoordinatorData,
HSEMDataUpdateCoordinator,
)
from custom_components.hsem.devices import HSEMDevice
from custom_components.hsem.entity import HSEMCoordinatorEntity, HSEMEntity
from custom_components.hsem.models.sensor_config import SensorConfig
from custom_components.hsem.utils.sensornames.ocpp import (
Expand Down Expand Up @@ -193,6 +194,9 @@ def __init__(

self._config_entry = config_entry
self._charger_index = charger_index
self._hsem_device = (
HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY
)
self._attr_unique_id = get_ocpp_charger_status_sensor_unique_id(
config_entry.entry_id, charger_index=charger_index
)
Expand Down Expand Up @@ -347,6 +351,9 @@ def __init__(

self._config_entry = config_entry
self._charger_index = charger_index
self._hsem_device = (
HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY
)
self._attr_unique_id = get_ocpp_charger_power_sensor_unique_id(
config_entry.entry_id, charger_index=charger_index
)
Expand Down Expand Up @@ -449,6 +456,9 @@ def __init__(

self._config_entry = config_entry
self._charger_index = charger_index
self._hsem_device = (
HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY
)
self._attr_unique_id = get_ocpp_charger_info_sensor_unique_id(
config_entry.entry_id, charger_index=charger_index
)
Expand Down Expand Up @@ -561,6 +571,9 @@ def __init__(

self._config_entry = config_entry
self._charger_index = charger_index
self._hsem_device = (
HSEMDevice.EV_SECONDARY if charger_index == 2 else HSEMDevice.EV_PRIMARY
)
self._attr_unique_id = get_ocpp_charger_sessions_sensor_unique_id(
config_entry.entry_id, charger_index=charger_index
)
Expand Down
Loading
Loading