From b7563c911410b3534975f7e61be4f03852b0f02b Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 13 May 2026 17:09:36 +0200 Subject: [PATCH 1/8] v0.1.0: rename domain to chargesplit, proper entity IDs, multi-device support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes: - Domain renamed Chargesplit → chargesplit (fixes HA lowercase requirement and brands proxy icon 404) - All entity unique IDs changed to {serial}_{suffix} pattern - Entity names now use has_entity_name=True; device name is "Chargesplit {serial}", yielding entity IDs like sensor.chargesplit_abc123_voltage_l1 - Existing installations must be removed and re-added Other changes: - Config flow sets unique ID per serial; prevents duplicate entries - Device identifier unified to serial across sensors and select entities - Select entity base class extracted (_BaseSelectEntity) to remove duplication - Select entity names simplified (Power Limit, Lock, Pause) - Coordinator cleaned up (removed unused imports and platforms list) - api.py: removed redundant self.host alias, deduplicated session/URL usage - Sensor icons updated (solar, house, charging power) - native_value replaces state property in sensors; _attr_* pattern throughout - Translations updated; single_instance_allowed replaced by already_configured - Min HA version: 2026.3.0 (brands proxy API for icon support) --- custom_components/Chargesplit/api.py | 37 --- custom_components/Chargesplit/sensor.py | 292 ------------------ custom_components/Chargesplit/strings.json | 20 -- .../Chargesplit/translations/en.json | 29 -- .../Chargesplit/translations/it.json | 30 -- custom_components/chargesplit/.DS_Store | Bin 0 -> 8196 bytes .../{Chargesplit => chargesplit}/__init__.py | 0 custom_components/chargesplit/api.py | 43 +++ .../brand/icon.png | Bin .../brand/icon@2x.png | Bin .../config_flow.py | 0 .../{Chargesplit => chargesplit}/const.py | 9 +- .../coordinator.py | 0 .../{Chargesplit => chargesplit}/entity.py | 11 +- .../manifest.json | 4 +- .../{Chargesplit => chargesplit}/select.py | 93 ++---- custom_components/chargesplit/sensor.py | 88 ++++++ custom_components/chargesplit/strings.json | 30 ++ .../chargesplit/translations/en.json | 31 ++ .../chargesplit/translations/it.json | 31 ++ 20 files changed, 265 insertions(+), 483 deletions(-) delete mode 100644 custom_components/Chargesplit/api.py delete mode 100644 custom_components/Chargesplit/sensor.py delete mode 100644 custom_components/Chargesplit/strings.json delete mode 100644 custom_components/Chargesplit/translations/en.json delete mode 100644 custom_components/Chargesplit/translations/it.json create mode 100644 custom_components/chargesplit/.DS_Store rename custom_components/{Chargesplit => chargesplit}/__init__.py (100%) create mode 100644 custom_components/chargesplit/api.py rename custom_components/{Chargesplit => chargesplit}/brand/icon.png (100%) rename custom_components/{Chargesplit => chargesplit}/brand/icon@2x.png (100%) rename custom_components/{Chargesplit => chargesplit}/config_flow.py (100%) rename custom_components/{Chargesplit => chargesplit}/const.py (54%) rename custom_components/{Chargesplit => chargesplit}/coordinator.py (100%) rename custom_components/{Chargesplit => chargesplit}/entity.py (76%) rename custom_components/{Chargesplit => chargesplit}/manifest.json (87%) rename custom_components/{Chargesplit => chargesplit}/select.py (72%) create mode 100644 custom_components/chargesplit/sensor.py create mode 100644 custom_components/chargesplit/strings.json create mode 100644 custom_components/chargesplit/translations/en.json create mode 100644 custom_components/chargesplit/translations/it.json diff --git a/custom_components/Chargesplit/api.py b/custom_components/Chargesplit/api.py deleted file mode 100644 index e11686b..0000000 --- a/custom_components/Chargesplit/api.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging - -import requests - -_LOGGER = logging.getLogger(__name__) - -_BASE_URL = "https://europe-west1-chargesplithome.cloudfunctions.net/secureEndpoint" - - -class ChargesplitApi: - def __init__(self, code: str, serial: str) -> None: - self.host = serial - self.code = code - self.serial = serial - - def get_data(self) -> bytes: - response = requests.post( - _BASE_URL, - data={"SECRET": self.code, "SERIAL": self.serial}, - ) - response.raise_for_status() - return response.content - - def test_auth(self) -> None: - response = requests.post( - _BASE_URL, - data={"SECRET": self.code, "SERIAL": self.serial}, - ) - if response.status_code != 200: - raise requests.ConnectionError(f"Auth failed with status {response.status_code}") - - def set_pilot_pwr(self, value: str) -> None: - _LOGGER.debug("Calling API PILOTCHANGE with value: %s", value) - requests.post( - _BASE_URL, - data={"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "PILOTCHANGE", "VALUE": value}, - ).raise_for_status() diff --git a/custom_components/Chargesplit/sensor.py b/custom_components/Chargesplit/sensor.py deleted file mode 100644 index 2702caa..0000000 --- a/custom_components/Chargesplit/sensor.py +++ /dev/null @@ -1,292 +0,0 @@ -import logging - -from homeassistant.config_entries import ConfigEntry - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) - -from homeassistant.const import ( - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, -) - -from .const import DOMAIN -from .entity import ChargesplitEntity -from .coordinator import ChargesplitDataUpdateCoordinator - -_LOGGER: logging.Logger = logging.getLogger(__package__) - - -async def async_setup_entry(hass, entry, async_add_devices): - coordinator = hass.data[DOMAIN][entry.entry_id] - serial = entry.data["serial"] - _LOGGER.info("Setting up ChargeSplit with serial " + serial) - - INSTRUMENTS = [ - # (id, description, key, unit, icon, device_class, state_class, serial, entity_category) - ( - "power_voltagel2", - "Voltage L2", - "VOLT2", - UnitOfElectricPotential.VOLT, - "mdi:lightning-bolt", - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "power_voltagel1", - "Voltage L1", - "VOLT1", - UnitOfElectricPotential.VOLT, - "mdi:lightning-bolt", - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "power_voltagel3", - "Voltage L3", - "VOLT3", - UnitOfElectricPotential.VOLT, - "mdi:lightning-bolt", - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "device_temperature", - "Temperature", - "TEMP", - UnitOfTemperature.CELSIUS, - "mdi:temperature-celsius", - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "state_class", - "Wallbox Status", - "STATUS", - None, - "mdi:ev-station", - None, - None, - serial, - None, - ), - ( - "device_model", - "Wallbox Model", - "MODEL", - None, - "mdi:ev-station", - None, - None, - serial, - EntityCategory.DIAGNOSTIC, - ), - ( - "device_firmware", - "Wallbox firmware", - "FWVERS", - None, - "mdi:ev-station", - None, - None, - serial, - EntityCategory.DIAGNOSTIC, - ), - ( - "device_serial", - "Wallbox serial", - "SERIAL", - None, - "mdi:ev-station", - None, - None, - serial, - EntityCategory.DIAGNOSTIC, - ), - ( - "power_charged_kWh", - "Charged kWh", - "TOTALCHARGED", - UnitOfEnergy.KILO_WATT_HOUR, - "mdi:speedometer", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - serial, - None, - ), - ( - "power_pilotamps", - "Pilot Amps", - "PILOTLIMIT", - UnitOfElectricCurrent.AMPERE, - "mdi:speedometer", - SensorDeviceClass.CURRENT, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "power_actual_amps", - "Actual Amps", - "AMP", - UnitOfElectricCurrent.AMPERE, - "mdi:current-ac", - SensorDeviceClass.CURRENT, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "power_solar_power", - "Actual solar power", - "SOLARPWR", - UnitOfPower.KILO_WATT, - "mdi:speedometer", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "power_house_power", - "Actual House Consumption", - "HOUSEPWR", - UnitOfPower.KILO_WATT, - "mdi:speedometer", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "power_car_charging", - "Car Charging Power", - "CHARGINGPWR", - UnitOfPower.KILO_WATT, - "mdi:speedometer", - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - serial, - None, - ), - ( - "house_charged_Wh", - "Daily House Wh", - "DAYHOUSE", - UnitOfEnergy.WATT_HOUR, - "mdi:speedometer", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - serial, - None, - ), - ( - "solar_charged_Wh", - "Daily Solar Wh", - "DAYSOLAR", - UnitOfEnergy.WATT_HOUR, - "mdi:speedometer", - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - serial, - None, - ), - ( - "schedule_state", - "Schedule", - "SCHEDULE", - None, - "mdi:calendar-clock", - None, - None, - serial, - EntityCategory.DIAGNOSTIC, - ), - ] - - sensors = [ - ChargesplitSensor( - coordinator, entry, id, description, key, unit, icon, device_class, state_class, serial, entity_category - ) - for id, description, key, unit, icon, device_class, state_class, serial, entity_category in INSTRUMENTS - ] - - async_add_devices(sensors, True) - - -class ChargesplitSensor(ChargesplitEntity, SensorEntity): - - def __init__( - self, - coordinator: ChargesplitDataUpdateCoordinator, - entry: ConfigEntry, - id: str, - description: str, - key: str, - unit: str, - icon: str, - device_class: str, - state_class: str, - serial, - entity_category=None, - ): - super().__init__(coordinator, entry) - self._id = f"{serial}-{description}" - self.description = description - self.key = key - self.unit = unit - self._icon = icon - self._device_class = device_class - self._state_class = state_class - self._attr_entity_category = entity_category - - @property - def native_value(self): - if not self.coordinator.data: - return None - return self.coordinator.data.get(self.key) - - @property - def native_unit_of_measurement(self): - return self.unit - - @property - def icon(self): - return self._icon - - @property - def device_class(self): - return self._device_class - - @property - def state_class(self): - return self._state_class - - @property - def name(self): - return f"{self.description}" - - @property - def id(self): - return f"{DOMAIN}_{self._id}" - - @property - def unique_id(self): - return f"{DOMAIN}-{self._id}-{self.coordinator.api.host}" diff --git a/custom_components/Chargesplit/strings.json b/custom_components/Chargesplit/strings.json deleted file mode 100644 index a887056..0000000 --- a/custom_components/Chargesplit/strings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Chargesplit", - "data": { - "serial": "Chargepoint Serial Number", - "code": "Authorization Code" - } - } - }, - "error": { - "auth": "Invalid serial or authorization code", - "cannot_connect": "Cannot connect to Chargesplit service. Check your network and try again." - }, - "abort": { - "already_configured": "Device is already configured" - } - } -} diff --git a/custom_components/Chargesplit/translations/en.json b/custom_components/Chargesplit/translations/en.json deleted file mode 100644 index a06593a..0000000 --- a/custom_components/Chargesplit/translations/en.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Chargesplit Authentication", - "description": "Enter your chargepoint serial and secret", - "data": { - "serial": "Wallbox Serial", - "code": "Wallbox Secret" - } - } - }, - "error": { - "auth": "Serial/secret is wrong." - }, - "abort": { - "single_instance_allowed": "Only a single instance is allowed." - } - }, "options": { - "step": { - "user": { - "title": "Polling time Configuration (NA)", - "data": { - "sync_interval": "Sync Interval to Fetch Data in Seconds" - } - } - } - } -} diff --git a/custom_components/Chargesplit/translations/it.json b/custom_components/Chargesplit/translations/it.json deleted file mode 100644 index 5f2cf15..0000000 --- a/custom_components/Chargesplit/translations/it.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Chargesplit login", - "description": "Inserisci il seriale della tua wallbox (CH..) ed il codice segreto", - "data": { - "serial": "Seriale Wallbox", - "code": "Codice Segreto" - } - } - }, - "error": { - "auth": "Seriale/codice errati" - }, - "abort": { - "single_instance_allowed": "Puoi installare una singola stazione" - } - }, "options": { - "step": { - "user": { - "title": "Tempo polling in secondi (ND)", - "data": { - "sync_interval": "Valore in secondi" - } - } - } - } - } - \ No newline at end of file diff --git a/custom_components/chargesplit/.DS_Store b/custom_components/chargesplit/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f4551bec9bf5543d89ff329f09e164b12f061bf9 GIT binary patch literal 8196 zcmeHMU2GIp6u#eWOJ`xE1GIL*9oTeB1f*C&(#lV={ZqMmjj1DLcE{ zB4{=yzNlz?()bs6@-OnBqQ)0Z6h$8uA27xTj7ELY1YcAhJag|X&=%f|L7AJ({pOr| z&b@ojd}nUXEMp9vMdJ>}qKq+xE+4f8)Lo_dGCldU|FJgbxyYIHuKk%&UFUzrawNMbM4}U>!?sh_ zLni6!yjjVqd0wXAR3lI-c-b?@0aZPDT-YJ)mz8KzUP9(NXE;Vj$t_mhNdi|Fg{$e zJS%IPgHC3`j4Z<1$l1E+(q*^AbX^);cBq)9i&^VX(JCC)5{e?$i0e)2UW0zqF4yo^ zF%<0-%|olN)SL7@{zzBeq}~V|R_nK^gC1>O(K6C-o!Y7nK4>{cMDwI(gSt^4vNKuJ zj%bElr(NBo50_;8BiatB+^RO{>6|+;L0j}9QrDq(>HGX;cV@go|D@^oKk1AdyPL*F zz41)m?4NSXeDI65lU6)Z()&(5hE;IIAVs6EN!LgG={gKU>|t4SX`P8Gewvd|GaR2WJ$!U&@u9AEvvR$u=oJmhqEUPqx@S+j_U} z`0pg!xrHLV>KkrujIUgC`l8{&jSEqYt=|x#1k|bmv89bf zNI~7aB|@pFRR`koB#BH2u~>HxO>;(5{wbcHW9QjV>^F9ic)k!z5XG%%C3bgW8~Oyl zQy9WNjA1{rFfaiJE)EgXkK$oGf-)W@o;DGaW+dx{QE-_EU@c^?%Q` zzyAxPunQv)M&SP$0aSOVx;tra*Lr*VwRW7Y{dD=ww<}8&F4T!~oKTeGgqQv>r29CT b`aa13%MwXL?O*>8poZsvc>W9O-O%0NbzS%t literal 0 HcmV?d00001 diff --git a/custom_components/Chargesplit/__init__.py b/custom_components/chargesplit/__init__.py similarity index 100% rename from custom_components/Chargesplit/__init__.py rename to custom_components/chargesplit/__init__.py diff --git a/custom_components/chargesplit/api.py b/custom_components/chargesplit/api.py new file mode 100644 index 0000000..2728d09 --- /dev/null +++ b/custom_components/chargesplit/api.py @@ -0,0 +1,43 @@ +import logging + +import requests + +from .const import CONF_CODE, CHARGEPOINT_SERIAL + +_LOGGER = logging.getLogger(__name__) + +_BASE_URL = "https://europe-west1-chargesplithome.cloudfunctions.net/secureEndpoint" + +_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded", + "Connection": "Keep-Alive", + "Accept-Encoding": "gzip", + "User-Agent": "Mozilla/5.0", +} + + +class ChargesplitApi: + def __init__(self, code: str, serial: str) -> None: + self.code = code + self.serial = serial + self.headers = {**_HEADERS, "Host": serial, "Origin": _BASE_URL} + + def get_data(self) -> bytes: + with requests.Session() as session: + response = session.post(_BASE_URL, data={"SECRET": self.code, "SERIAL": self.serial}) + response.raise_for_status() + return response.content + + def test_auth(self) -> None: + with requests.Session() as session: + response = session.post(_BASE_URL, data={"SECRET": self.code, "SERIAL": self.serial}) + if response.status_code != 200: + raise requests.ConnectionError(f"Auth failed with status {response.status_code}") + + def set_pilot_pwr(self, value: str) -> None: + _LOGGER.debug("Calling API PILOTCHANGE with value: %s", value) + with requests.Session() as session: + session.post( + _BASE_URL, + data={"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "PILOTCHANGE", "VALUE": value}, + ).raise_for_status() diff --git a/custom_components/Chargesplit/brand/icon.png b/custom_components/chargesplit/brand/icon.png similarity index 100% rename from custom_components/Chargesplit/brand/icon.png rename to custom_components/chargesplit/brand/icon.png diff --git a/custom_components/Chargesplit/brand/icon@2x.png b/custom_components/chargesplit/brand/icon@2x.png similarity index 100% rename from custom_components/Chargesplit/brand/icon@2x.png rename to custom_components/chargesplit/brand/icon@2x.png diff --git a/custom_components/Chargesplit/config_flow.py b/custom_components/chargesplit/config_flow.py similarity index 100% rename from custom_components/Chargesplit/config_flow.py rename to custom_components/chargesplit/config_flow.py diff --git a/custom_components/Chargesplit/const.py b/custom_components/chargesplit/const.py similarity index 54% rename from custom_components/Chargesplit/const.py rename to custom_components/chargesplit/const.py index b080181..fd5344a 100644 --- a/custom_components/Chargesplit/const.py +++ b/custom_components/chargesplit/const.py @@ -1,14 +1,11 @@ """Constants for Chargesplit.""" -# Base component constants NAME = "Chargesplit Domus" -DOMAIN = "Chargesplit" +DOMAIN = "chargesplit" - -# Configuration and options CONF_ENABLED = "enabled" CONF_CODE = "code" CONF_SYNC_INTERVAL = "sync_interval" -CHARGEPOINT_SERIAL = "serial" +CHARGEPOINT_SERIAL = "serial" DEFAULT_SYNC_INTERVAL = 60 # seconds -CONF_MAX_CHARGING_CURRENT_KEY = "PILOTLIMIT" \ No newline at end of file +CONF_MAX_CHARGING_CURRENT_KEY = "PILOTLIMIT" diff --git a/custom_components/Chargesplit/coordinator.py b/custom_components/chargesplit/coordinator.py similarity index 100% rename from custom_components/Chargesplit/coordinator.py rename to custom_components/chargesplit/coordinator.py diff --git a/custom_components/Chargesplit/entity.py b/custom_components/chargesplit/entity.py similarity index 76% rename from custom_components/Chargesplit/entity.py rename to custom_components/chargesplit/entity.py index 06da3c2..563f54b 100644 --- a/custom_components/Chargesplit/entity.py +++ b/custom_components/chargesplit/entity.py @@ -2,13 +2,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, NAME +from .const import DOMAIN from .coordinator import ChargesplitDataUpdateCoordinator _LOGGER: logging.Logger = logging.getLogger(__package__) class ChargesplitEntity(CoordinatorEntity): + _attr_has_entity_name = True + def __init__(self, coordinator: ChargesplitDataUpdateCoordinator, entry): super().__init__(coordinator) self.entry = entry @@ -16,10 +18,11 @@ def __init__(self, coordinator: ChargesplitDataUpdateCoordinator, entry): @property def device_info(self): data = self.coordinator.data or {} + serial = self.coordinator.api.serial return { - "identifiers": {(DOMAIN, self.coordinator.api.host)}, - "name": NAME, - "manufacturer": NAME, + "identifiers": {(DOMAIN, serial)}, + "name": f"Chargesplit {serial}", + "manufacturer": "Chargesplit", "model": data.get("MODEL"), "sw_version": str(data["FWVERS"]) if "FWVERS" in data else None, } diff --git a/custom_components/Chargesplit/manifest.json b/custom_components/chargesplit/manifest.json similarity index 87% rename from custom_components/Chargesplit/manifest.json rename to custom_components/chargesplit/manifest.json index 4331e4a..f98a8ad 100644 --- a/custom_components/Chargesplit/manifest.json +++ b/custom_components/chargesplit/manifest.json @@ -1,5 +1,5 @@ { - "domain": "Chargesplit", + "domain": "chargesplit", "name": "Chargesplit Domus", "codeowners": ["@nanomad"], "config_flow": true, @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/nanomad/ChargesplitHomeAssistant/issues", "requirements": ["requests>=2.28.0"], - "version": "0.0.7" + "version": "0.1.0" } diff --git a/custom_components/Chargesplit/select.py b/custom_components/chargesplit/select.py similarity index 72% rename from custom_components/Chargesplit/select.py rename to custom_components/chargesplit/select.py index 198b6b5..449908f 100644 --- a/custom_components/Chargesplit/select.py +++ b/custom_components/chargesplit/select.py @@ -7,7 +7,7 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, NAME +from .const import DOMAIN from .coordinator import ChargesplitDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -24,22 +24,22 @@ OPERATION_MODE = SelectEntityDescription( key="operation_mode", - name="Select Chargepoint Power AMPS", + name="Power Limit", icon="mdi:ev-charger", entity_category=EntityCategory.CONFIG, ) LOCK_MODE = SelectEntityDescription( key="lock_mode", - name="Send Lock/unlock command", - icon="mdi:ev-charger", + name="Lock", + icon="mdi:lock", entity_category=EntityCategory.CONFIG, ) PAUSE_MODE = SelectEntityDescription( key="pause_mode", - name="Send pause/restart command", - icon="mdi:ev-charger", + name="Pause", + icon="mdi:pause-circle", entity_category=EntityCategory.CONFIG, ) @@ -49,7 +49,6 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the select entities from a config entry.""" coordinator: ChargesplitDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] code = config_entry.data["code"] serial = config_entry.data["serial"] @@ -61,13 +60,27 @@ async def async_setup_entry( ]) -class ChargepointOperationModeEntity(CoordinatorEntity, SelectEntity): - """Entity for selecting the chargepoint power in amps. +class _BaseSelectEntity(SelectEntity): + _attr_should_poll = False + _attr_has_entity_name = True - Extends CoordinatorEntity so _attr_current_option reflects the PILOTLIMIT - value reported by the device after each coordinator refresh. - """ + def __init__(self, description: SelectEntityDescription, serial: str, code: str) -> None: + self.entity_description = description + self._attr_unique_id = f"{serial}_{description.key}" + self._attr_current_option = None + self.serial = serial + self.code = code + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, self.serial)}, + name=f"Chargesplit {self.serial}", + manufacturer="Chargesplit", + ) + + +class ChargepointOperationModeEntity(CoordinatorEntity, _BaseSelectEntity): def __init__( self, description: SelectEntityDescription, @@ -76,11 +89,8 @@ def __init__( coordinator: ChargesplitDataUpdateCoordinator, ) -> None: CoordinatorEntity.__init__(self, coordinator) - self.entity_description = description - self._attr_unique_id = f"{serial}-{description.key}" + _BaseSelectEntity.__init__(self, description, serial, code) self._attr_options = CHARGEPOINT_OPERATION_MODES - self.serial = serial - self.code = code @property def current_option(self) -> str | None: @@ -89,16 +99,7 @@ def current_option(self) -> str | None: value = self.coordinator.data.get("PILOTLIMIT") return str(value) if value is not None else None - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self.serial)}, - name=NAME, - manufacturer=NAME, - ) - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" data = {"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "PILOTCHANGE", "VALUE": option} try: response = await self.hass.async_add_executor_job( @@ -110,29 +111,12 @@ async def async_select_option(self, option: str) -> None: await self.coordinator.async_request_refresh() -class ChargepointLockModeEntity(SelectEntity): - """Entity for sending lock/unlock commands.""" - - _attr_should_poll = False - +class ChargepointLockModeEntity(_BaseSelectEntity): def __init__(self, description: SelectEntityDescription, serial: str, code: str) -> None: - self.entity_description = description - self._attr_unique_id = f"{serial}-{description.key}" + super().__init__(description, serial, code) self._attr_options = CHARGEPOINT_LOCK_MODES - self._attr_current_option = None - self.serial = serial - self.code = code - - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self.serial)}, - name=NAME, - manufacturer=NAME, - ) async def async_select_option(self, option: str) -> None: - """Change the selected option.""" data = {"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "LOCK", "VALUE": option} try: response = await self.hass.async_add_executor_job( @@ -145,29 +129,12 @@ async def async_select_option(self, option: str) -> None: self.async_write_ha_state() -class ChargepointPauseModeEntity(SelectEntity): - """Entity for sending pause/restart commands.""" - - _attr_should_poll = False - +class ChargepointPauseModeEntity(_BaseSelectEntity): def __init__(self, description: SelectEntityDescription, serial: str, code: str) -> None: - self.entity_description = description - self._attr_unique_id = f"{serial}-{description.key}" + super().__init__(description, serial, code) self._attr_options = CHARGEPOINT_PAUSE_MODES - self._attr_current_option = None - self.serial = serial - self.code = code - - @property - def device_info(self) -> DeviceInfo: - return DeviceInfo( - identifiers={(DOMAIN, self.serial)}, - name=NAME, - manufacturer=NAME, - ) async def async_select_option(self, option: str) -> None: - """Change the selected option.""" data = {"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "PAUSERESTART", "VALUE": option} try: response = await self.hass.async_add_executor_job( diff --git a/custom_components/chargesplit/sensor.py b/custom_components/chargesplit/sensor.py new file mode 100644 index 0000000..44ec004 --- /dev/null +++ b/custom_components/chargesplit/sensor.py @@ -0,0 +1,88 @@ +import logging +import json + +from homeassistant.config_entries import ConfigEntry + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) + +from homeassistant.const import ( + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) + +from .const import DOMAIN +from .entity import ChargesplitEntity +from .coordinator import ChargesplitDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +# (unique_id_suffix, name, data_key, unit, icon, device_class, state_class, entity_category) +INSTRUMENTS = [ + ("voltage_l1", "Voltage L1", "VOLT1", UnitOfElectricPotential.VOLT, "mdi:lightning-bolt", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, None), + ("voltage_l2", "Voltage L2", "VOLT2", UnitOfElectricPotential.VOLT, "mdi:lightning-bolt", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, None), + ("voltage_l3", "Voltage L3", "VOLT3", UnitOfElectricPotential.VOLT, "mdi:lightning-bolt", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, None), + ("temperature", "Temperature", "TEMP", UnitOfTemperature.CELSIUS, "mdi:thermometer", SensorDeviceClass.TEMPERATURE, SensorStateClass.MEASUREMENT, None), + ("status", "Wallbox Status", "STATUS", None, "mdi:ev-station", None, None, None), + ("model", "Model", "MODEL", None, "mdi:information-outline", None, None, EntityCategory.DIAGNOSTIC), + ("firmware", "Firmware", "FWVERS", None, "mdi:chip", None, None, EntityCategory.DIAGNOSTIC), + ("serial", "Serial", "SERIAL", None, "mdi:barcode", None, None, EntityCategory.DIAGNOSTIC), + ("total_charged_kwh", "Total Charged", "TOTALCHARGED", UnitOfEnergy.KILO_WATT_HOUR, "mdi:battery-charging", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, None), + ("pilot_amps", "Pilot Amps", "PILOTLIMIT", UnitOfElectricCurrent.AMPERE, "mdi:current-ac", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, None), + ("actual_amps", "Actual Amps", "AMP", UnitOfElectricCurrent.AMPERE, "mdi:current-ac", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, None), + ("solar_power", "Solar Power", "SOLARPWR", UnitOfPower.KILO_WATT, "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, None), + ("house_power", "House Consumption", "HOUSEPWR", UnitOfPower.KILO_WATT, "mdi:home-lightning-bolt", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, None), + ("charging_power", "Charging Power", "CHARGINGPWR", UnitOfPower.KILO_WATT, "mdi:ev-plug-type2", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, None), + ("daily_house_wh", "Daily House Energy", "DAYHOUSE", UnitOfEnergy.WATT_HOUR, "mdi:home-lightning-bolt", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, None), + ("daily_solar_wh", "Daily Solar Energy", "DAYSOLAR", UnitOfEnergy.WATT_HOUR, "mdi:solar-power", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, None), + ("schedule", "Schedule", "SCHEDULE", None, "mdi:calendar-clock", None, None, EntityCategory.DIAGNOSTIC), +] + + +async def async_setup_entry(hass, entry: ConfigEntry, async_add_devices): + coordinator = hass.data[DOMAIN][entry.entry_id] + serial = entry.data["serial"] + + async_add_devices([ + ChargesplitSensor(coordinator, entry, serial, *instrument) + for instrument in INSTRUMENTS + ], True) + + +class ChargesplitSensor(ChargesplitEntity, SensorEntity): + + def __init__( + self, + coordinator: ChargesplitDataUpdateCoordinator, + entry: ConfigEntry, + serial: str, + uid_suffix: str, + name: str, + key: str, + unit: str, + icon: str, + device_class, + state_class, + entity_category, + ): + super().__init__(coordinator, entry) + self._attr_unique_id = f"{serial}_{uid_suffix}" + self._attr_name = name + self._attr_icon = icon + self._attr_device_class = device_class + self._attr_state_class = state_class + self._attr_native_unit_of_measurement = unit + self._attr_entity_category = entity_category + self.key = key + + @property + def native_value(self): + data = json.loads(self.coordinator.data) + return data.get(self.key) diff --git a/custom_components/chargesplit/strings.json b/custom_components/chargesplit/strings.json new file mode 100644 index 0000000..e114034 --- /dev/null +++ b/custom_components/chargesplit/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "Chargesplit", + "data": { + "serial": "Wallbox Serial", + "code": "Wallbox Secret" + } + } + }, + "error": { + "auth": "Serial or secret is incorrect.", + "cannot_connect": "Cannot connect to Chargesplit service. Check your network and try again." + }, + "abort": { + "already_configured": "A wallbox with this serial is already configured." + } + }, + "options": { + "step": { + "user": { + "title": "Polling Configuration", + "data": { + "sync_interval": "Sync Interval (seconds)" + } + } + } + } +} diff --git a/custom_components/chargesplit/translations/en.json b/custom_components/chargesplit/translations/en.json new file mode 100644 index 0000000..f5685a7 --- /dev/null +++ b/custom_components/chargesplit/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Chargesplit Authentication", + "description": "Enter your wallbox serial and secret", + "data": { + "serial": "Wallbox Serial", + "code": "Wallbox Secret" + } + } + }, + "error": { + "auth": "Serial or secret is incorrect.", + "cannot_connect": "Cannot connect to Chargesplit service. Check your network and try again." + }, + "abort": { + "already_configured": "A wallbox with this serial is already configured." + } + }, + "options": { + "step": { + "user": { + "title": "Polling Configuration", + "data": { + "sync_interval": "Sync Interval (seconds)" + } + } + } + } +} diff --git a/custom_components/chargesplit/translations/it.json b/custom_components/chargesplit/translations/it.json new file mode 100644 index 0000000..018ad14 --- /dev/null +++ b/custom_components/chargesplit/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Chargesplit login", + "description": "Inserisci il seriale della tua wallbox (CH..) ed il codice segreto", + "data": { + "serial": "Seriale Wallbox", + "code": "Codice Segreto" + } + } + }, + "error": { + "auth": "Seriale o codice errati.", + "cannot_connect": "Impossibile connettersi al servizio Chargesplit. Verifica la rete e riprova." + }, + "abort": { + "already_configured": "Una wallbox con questo seriale è già configurata." + } + }, + "options": { + "step": { + "user": { + "title": "Configurazione polling", + "data": { + "sync_interval": "Intervallo di sincronizzazione (secondi)" + } + } + } + } +} From fc17e0b5cb0106543b164eb606c50015dee038cb Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 13 May 2026 17:39:54 +0200 Subject: [PATCH 2/8] Fix PR review issues: sensor double-decode, dead api.py headers/imports, .DS_Store - sensor.py: remove spurious json.loads() on already-decoded coordinator.data, restore None guard, drop unused json import - api.py: remove dead self.headers (serial is not a hostname), remove unused CONF_CODE/CHARGEPOINT_SERIAL imports, simplify per-call Sessions to requests.post - Add .gitignore with **/.DS_Store, remove committed .DS_Store --- .gitignore | 1 + custom_components/chargesplit/.DS_Store | Bin 8196 -> 0 bytes custom_components/chargesplit/api.py | 33 +++++++----------------- custom_components/chargesplit/sensor.py | 6 ++--- 4 files changed, 14 insertions(+), 26 deletions(-) create mode 100644 .gitignore delete mode 100644 custom_components/chargesplit/.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79b5594 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/.DS_Store diff --git a/custom_components/chargesplit/.DS_Store b/custom_components/chargesplit/.DS_Store deleted file mode 100644 index f4551bec9bf5543d89ff329f09e164b12f061bf9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6u#eWOJ`xE1GIL*9oTeB1f*C&(#lV={ZqMmjj1DLcE{ zB4{=yzNlz?()bs6@-OnBqQ)0Z6h$8uA27xTj7ELY1YcAhJag|X&=%f|L7AJ({pOr| z&b@ojd}nUXEMp9vMdJ>}qKq+xE+4f8)Lo_dGCldU|FJgbxyYIHuKk%&UFUzrawNMbM4}U>!?sh_ zLni6!yjjVqd0wXAR3lI-c-b?@0aZPDT-YJ)mz8KzUP9(NXE;Vj$t_mhNdi|Fg{$e zJS%IPgHC3`j4Z<1$l1E+(q*^AbX^);cBq)9i&^VX(JCC)5{e?$i0e)2UW0zqF4yo^ zF%<0-%|olN)SL7@{zzBeq}~V|R_nK^gC1>O(K6C-o!Y7nK4>{cMDwI(gSt^4vNKuJ zj%bElr(NBo50_;8BiatB+^RO{>6|+;L0j}9QrDq(>HGX;cV@go|D@^oKk1AdyPL*F zz41)m?4NSXeDI65lU6)Z()&(5hE;IIAVs6EN!LgG={gKU>|t4SX`P8Gewvd|GaR2WJ$!U&@u9AEvvR$u=oJmhqEUPqx@S+j_U} z`0pg!xrHLV>KkrujIUgC`l8{&jSEqYt=|x#1k|bmv89bf zNI~7aB|@pFRR`koB#BH2u~>HxO>;(5{wbcHW9QjV>^F9ic)k!z5XG%%C3bgW8~Oyl zQy9WNjA1{rFfaiJE)EgXkK$oGf-)W@o;DGaW+dx{QE-_EU@c^?%Q` zzyAxPunQv)M&SP$0aSOVx;tra*Lr*VwRW7Y{dD=ww<}8&F4T!~oKTeGgqQv>r29CT b`aa13%MwXL?O*>8poZsvc>W9O-O%0NbzS%t diff --git a/custom_components/chargesplit/api.py b/custom_components/chargesplit/api.py index 2728d09..a7f2f58 100644 --- a/custom_components/chargesplit/api.py +++ b/custom_components/chargesplit/api.py @@ -2,42 +2,29 @@ import requests -from .const import CONF_CODE, CHARGEPOINT_SERIAL - _LOGGER = logging.getLogger(__name__) _BASE_URL = "https://europe-west1-chargesplithome.cloudfunctions.net/secureEndpoint" -_HEADERS = { - "Content-Type": "application/x-www-form-urlencoded", - "Connection": "Keep-Alive", - "Accept-Encoding": "gzip", - "User-Agent": "Mozilla/5.0", -} - class ChargesplitApi: def __init__(self, code: str, serial: str) -> None: self.code = code self.serial = serial - self.headers = {**_HEADERS, "Host": serial, "Origin": _BASE_URL} def get_data(self) -> bytes: - with requests.Session() as session: - response = session.post(_BASE_URL, data={"SECRET": self.code, "SERIAL": self.serial}) - response.raise_for_status() - return response.content + response = requests.post(_BASE_URL, data={"SECRET": self.code, "SERIAL": self.serial}) + response.raise_for_status() + return response.content def test_auth(self) -> None: - with requests.Session() as session: - response = session.post(_BASE_URL, data={"SECRET": self.code, "SERIAL": self.serial}) - if response.status_code != 200: - raise requests.ConnectionError(f"Auth failed with status {response.status_code}") + response = requests.post(_BASE_URL, data={"SECRET": self.code, "SERIAL": self.serial}) + if response.status_code != 200: + raise requests.ConnectionError(f"Auth failed with status {response.status_code}") def set_pilot_pwr(self, value: str) -> None: _LOGGER.debug("Calling API PILOTCHANGE with value: %s", value) - with requests.Session() as session: - session.post( - _BASE_URL, - data={"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "PILOTCHANGE", "VALUE": value}, - ).raise_for_status() + requests.post( + _BASE_URL, + data={"SECRET": self.code, "SERIAL": self.serial, "COMMAND": "PILOTCHANGE", "VALUE": value}, + ).raise_for_status() diff --git a/custom_components/chargesplit/sensor.py b/custom_components/chargesplit/sensor.py index 44ec004..945ce14 100644 --- a/custom_components/chargesplit/sensor.py +++ b/custom_components/chargesplit/sensor.py @@ -1,5 +1,4 @@ import logging -import json from homeassistant.config_entries import ConfigEntry @@ -84,5 +83,6 @@ def __init__( @property def native_value(self): - data = json.loads(self.coordinator.data) - return data.get(self.key) + if not self.coordinator.data: + return None + return self.coordinator.data.get(self.key) From 5b994de39171842d2d106052a656451f8ee46876 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 13 May 2026 17:42:52 +0200 Subject: [PATCH 3/8] Remove dead NAME and CONF_ENABLED constants from const.py --- custom_components/chargesplit/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/chargesplit/const.py b/custom_components/chargesplit/const.py index fd5344a..2d957ed 100644 --- a/custom_components/chargesplit/const.py +++ b/custom_components/chargesplit/const.py @@ -1,8 +1,6 @@ """Constants for Chargesplit.""" -NAME = "Chargesplit Domus" DOMAIN = "chargesplit" -CONF_ENABLED = "enabled" CONF_CODE = "code" CONF_SYNC_INTERVAL = "sync_interval" CHARGEPOINT_SERIAL = "serial" From 48614cc77c36c101a945382e9035bcd856cba583 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 13 May 2026 22:06:31 +0200 Subject: [PATCH 4/8] Auto-migrate legacy `Chargesplit` config + registry to `chargesplit` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.1.0 renames the domain (brands proxy at brands.home-assistant.io requires all-lowercase) and reshapes every entity unique_id. Without migration, existing v0.0.x installs end up with orphaned config entries, entity registry rows still on platform="Chargesplit", and device identifiers still keyed on ("Chargesplit", serial) — all pointing at an integration that no longer exists on disk. The README's "remove and re-add" workaround loses dashboards, automations, and long-term statistics history. This commit migrates that state on next startup. Contract: - The legacy config entry is removed. - A new `chargesplit` entry is created with the same credentials, so users don't have to re-enter anything. - Every entity row keeps its `entity_id` — statistics history is keyed on entity_id, so preserving it is the whole point. Its `unique_id` is rewritten to `{serial}_{key}` and its `platform` to `chargesplit`. - The device row keeps its `device_id`; identifiers swap to ("chargesplit", serial). - Unrecognized-unique_id rows are detached and called out in a persistent notification rather than getting cascade-deleted. Architecture notes: 1. Trigger. HA's bootstrap only loads a domain that has YAML config or config entries (`hass.config_entries.async_domains()`). After upgrade no `chargesplit` entry exists yet — only orphaned `Chargesplit` ones. To force `chargesplit.async_setup` to fire, `custom_components/Chargesplit/` ships as a thin shim whose manifest declares `dependencies: ["chargesplit"]`. HA loads the shim because the orphaned entry references it, and the dependency forces `chargesplit` to load first. The shim's `async_setup_entry` is a no-op — by the time HA gets there, migration has already removed the legacy entry. 2. Sequencing (all public API). The new entry has to exist before rows can be re-pointed (`async_update_entity_platform` requires `new_config_entry_id`), but its platform setup also has to run after row rewrites — otherwise the platform's freshly-created entities collide on unique_id and `async_update_entity_platform` refuses to migrate loaded rows. We avoid both: a) Pre-rewrite legacy rows in place: new platform, new unique_id, `new_config_entry_id=legacy_entry.entry_id` (the gating value, not a real change). b) Pre-rewrite device identifiers, leaving config-entry membership. c) `async_add` the new entry. Its platforms call `async_get_or_create(... platform, unique_id)` and `async_get_or_create(... identifiers)`, which match the pre-migrated rows and re-link them to the new entry as a side effect. d) Remove the legacy entry. The device already has both entries by now; HA cascade-clears the legacy id. 3. Re-entry guard. `async_add` in step c awaits the new entry's setup; if `chargesplit` isn't already loaded, HA loads it inline, re-entering `chargesplit.async_setup` and the migration. The inner call would complete the work; the outer call would trip over an already-removed legacy entry. A `hass.data` flag short-circuits inner re-entry. 4. Idempotency. If we crash mid-migration and re-run, the next pass detects the half-created `chargesplit` entry by serial and reuses it instead of duplicating. Tests: - `test_migration_mapping.py`: parametrized over every v0.0.7 unique_id shape, plus negative cases including hyphenated serials. - `test_migration.py`: seeds the v0.0.7 registry state and asserts entity_id preservation, unique_id + platform rewrite, device identifier + config-entry swap, idempotency, resume-from-partial-state, and the warning path for unrecognized unique_ids. - `test_setup_contract.py`: v0.1.0 fresh-install baseline. Parallel to the v0.0.7 contract from main but anchored on `chargesplit` (lowercase), with `has_entity_name=True`, the renamed short labels ("Model" vs "Wallbox Model", "Power Limit" vs "Select Chargepoint Power AMPS"), and the new device identifier tuple. Removes `tests/test_setup.py` — the v0.0.7 contract that came over via the main merge but imports `custom_components.Chargesplit.api`, which doesn't exist on this branch. It's superseded by `test_setup_contract.py`. Cleanup later: once we're confident nobody is upgrading from <0.1.0 (v0.3.0 or v1.0.0), delete `migration.py`, the shim folder, the `async_setup` hook, and the two migration tests. The `test_setup_contract.py` baseline stays. Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/Chargesplit/__init__.py | 35 ++ custom_components/Chargesplit/manifest.json | 13 + custom_components/chargesplit/__init__.py | 6 + custom_components/chargesplit/migration.py | 256 +++++++++++++ tests/test_migration.py | 262 +++++++++++++ tests/test_migration_mapping.py | 76 ++++ tests/test_setup.py | 383 -------------------- tests/test_setup_contract.py | 375 +++++++++++++++++++ 8 files changed, 1023 insertions(+), 383 deletions(-) create mode 100644 custom_components/Chargesplit/__init__.py create mode 100644 custom_components/Chargesplit/manifest.json create mode 100644 custom_components/chargesplit/migration.py create mode 100644 tests/test_migration.py create mode 100644 tests/test_migration_mapping.py delete mode 100644 tests/test_setup.py create mode 100644 tests/test_setup_contract.py diff --git a/custom_components/Chargesplit/__init__.py b/custom_components/Chargesplit/__init__.py new file mode 100644 index 0000000..2b7fcf2 --- /dev/null +++ b/custom_components/Chargesplit/__init__.py @@ -0,0 +1,35 @@ +"""Legacy `Chargesplit` (capitalized) domain shim. + +v0.0.x used DOMAIN="Chargesplit". v0.1.0 renamed to lowercase. Existing +installs have orphaned config entries under the old capitalized domain; +this shim exists only so HA can resolve those entries to *something* on +disk, which in turn forces `chargesplit` (our real integration, declared +as a dependency in this shim's manifest) to load. `chargesplit.async_setup` +then runs the migration that rehomes the entry under the lowercase domain. + +Once an install has been migrated, no `Chargesplit` config entries remain +and this shim is never loaded again. It can be deleted from the release +once we're confident nobody is upgrading from <0.1.0. +""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + # An entry only reaches us if migration didn't remove it during + # `chargesplit.async_setup`. The most likely cause is an unrecognized + # entity unique_id; the migration logs and notifies in that case. + # We return True so HA marks the entry LOADED rather than erroring — + # without this the entry stays in setup_error and confuses the UI. + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + return True diff --git a/custom_components/Chargesplit/manifest.json b/custom_components/Chargesplit/manifest.json new file mode 100644 index 0000000..c07c768 --- /dev/null +++ b/custom_components/Chargesplit/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "Chargesplit", + "name": "Chargesplit (legacy domain shim)", + "codeowners": ["@nanomad"], + "config_flow": false, + "dependencies": ["chargesplit"], + "documentation": "https://github.com/nanomad/ChargesplitHomeAssistant", + "integration_type": "system", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/nanomad/ChargesplitHomeAssistant/issues", + "requirements": [], + "version": "0.1.0" +} diff --git a/custom_components/chargesplit/__init__.py b/custom_components/chargesplit/__init__.py index 664bd1c..eb07ca2 100644 --- a/custom_components/chargesplit/__init__.py +++ b/custom_components/chargesplit/__init__.py @@ -14,6 +14,7 @@ DOMAIN, ) from .coordinator import ChargesplitDataUpdateCoordinator +from .migration import async_migrate_legacy_domain PLATFORMS = [Platform.SENSOR, Platform.SELECT] @@ -22,6 +23,11 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + await async_migrate_legacy_domain(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/chargesplit/migration.py b/custom_components/chargesplit/migration.py new file mode 100644 index 0000000..80b2c65 --- /dev/null +++ b/custom_components/chargesplit/migration.py @@ -0,0 +1,256 @@ +"""One-shot migration from the legacy capitalized `Chargesplit` domain. + +v0.0.x shipped under DOMAIN="Chargesplit" because the brands proxy at +brands.home-assistant.io was case-insensitive at the time. It later started +requiring all-lowercase domains, so v0.1.0 renamed to `chargesplit`. Existing +installs end up with orphaned entries: the old config entry, all entity +registry rows (platform="Chargesplit", verbose unique_ids), and the device +row (identifiers={("Chargesplit", serial)}) are pointed at an integration +that no longer exists on disk. + +This module rewrites that state in-place so entity_ids stay stable — +preserving long-term-statistics history (keyed by entity_id), dashboards, +and automations. + +The migration runs from `async_setup` on the new `chargesplit` domain. For +that to fire at all when only orphaned `Chargesplit` entries exist, the +sibling `custom_components/Chargesplit/` shim manifest declares +`dependencies: ["chargesplit"]` — HA loads the shim because the orphaned +entry references it, and the dependency forces `chargesplit.async_setup` to +run first. +""" + +from __future__ import annotations + +import logging +from types import MappingProxyType +from typing import Final + +from homeassistant.components import persistent_notification +from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +LEGACY_DOMAIN: Final = "Chargesplit" + +# Description text in the old (v0.0.x) sensor unique_id `Chargesplit-{serial}-{description}-{serial}` +# → suffix used in the v0.1.0 unique_id `{serial}_{suffix}`. +_SENSOR_DESCRIPTION_TO_SUFFIX: Final[dict[str, str]] = { + "Voltage L1": "voltage_l1", + "Voltage L2": "voltage_l2", + "Voltage L3": "voltage_l3", + "Temperature": "temperature", + "Wallbox Status": "status", + "Wallbox Model": "model", + "Wallbox firmware": "firmware", + "Wallbox serial": "serial", + "Charged kWh": "total_charged_kwh", + "Pilot Amps": "pilot_amps", + "Actual Amps": "actual_amps", + "Actual solar power": "solar_power", + "Actual House Consumption": "house_power", + "Car Charging Power": "charging_power", + "Daily House Wh": "daily_house_wh", + "Daily Solar Wh": "daily_solar_wh", + "Schedule": "schedule", +} + +# v0.0.x select unique_ids are `{serial}-{key}` for these keys. +_SELECT_KEYS: Final = ("operation_mode", "lock_mode", "pause_mode") + + +def migrate_unique_id(old: str, serial: str) -> str | None: + """Translate a v0.0.x unique_id to the v0.1.0 shape, or None if unrecognized. + + Anchored on the known serial so that serials containing dashes parse safely. + """ + if old.startswith(f"{LEGACY_DOMAIN}-{serial}-") and old.endswith(f"-{serial}"): + # Sensor: Chargesplit-{serial}-{description}-{serial} + description = old[len(f"{LEGACY_DOMAIN}-{serial}-") : -len(f"-{serial}")] + suffix = _SENSOR_DESCRIPTION_TO_SUFFIX.get(description) + if suffix is None: + return None + return f"{serial}_{suffix}" + + for key in _SELECT_KEYS: + if old == f"{serial}-{key}": + return f"{serial}_{key}" + + return None + + +_REENTRY_GUARD_KEY = "_chargesplit_migration_in_progress" + + +async def async_migrate_legacy_domain(hass: HomeAssistant) -> None: + """Rewrite orphaned `Chargesplit` config + registry rows to `chargesplit`. + + Idempotent: safe to call repeatedly. If interrupted mid-way and re-run, + it picks up the partially-migrated state (new chargesplit entry already + exists for the serial) instead of duplicating. + + Sequencing rationale (all public API): + + 1. Rewrite legacy entity rows: change `platform` to `chargesplit` and + `unique_id` to the v0.1.0 shape, but leave `config_entry_id` pointing + at the legacy entry. The new entry doesn't exist yet, and that's + fine — `async_update_entity_platform` accepts a same-value + `new_config_entry_id` (it's just gating against accidental orphaning + when the entity is already linked). + 2. Rewrite the device's identifier tuple. Leave config-entry membership + alone. + 3. `async_add` the new entry. Its platform setup calls + `async_get_or_create(domain, "chargesplit", new_unique_id)` and + `async_get_or_create(... identifiers=...)`, which match the rows + we pre-migrated in steps 1-2 and re-link them to the new entry as + a side effect of `_async_update_entity` / `_async_update_device`. + 4. Remove the legacy entry. The new entry is already in the device's + config_entries set; HA cascade-clears the legacy id during removal. + + Re-entry guard: `async_add` in step 3 awaits the new entry's setup. If + `chargesplit` hasn't been loaded yet (the typical bootstrap order has + HA load us first via the legacy-domain shim's `dependencies`, but tests + and edge cases can invert this), HA loads it inline, which calls + `chargesplit.async_setup` again, which calls *this* function again. + The inner call would complete the migration first; the outer call would + then trip over an already-removed legacy entry. The hass.data flag + below short-circuits the inner call instead. + """ + if hass.data.get(_REENTRY_GUARD_KEY): + return + + legacy_entries = list(hass.config_entries.async_entries(LEGACY_DOMAIN)) + if not legacy_entries: + return + + hass.data[_REENTRY_GUARD_KEY] = True + try: + await _do_migrate(hass, legacy_entries) + finally: + hass.data.pop(_REENTRY_GUARD_KEY, None) + + +async def _do_migrate( + hass: HomeAssistant, legacy_entries: list[ConfigEntry] +) -> None: + + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + migrated_count = 0 + unrecognized: list[str] = [] + + for legacy_entry in legacy_entries: + serial = legacy_entry.data.get("serial") + if not serial: + _LOGGER.warning( + "Legacy Chargesplit entry %s has no serial in data; skipping", + legacy_entry.entry_id, + ) + continue + + # Step 1: rewrite entity rows. Keep them pointed at the legacy entry + # for now; the new entry's platform setup will adopt them via + # `async_get_or_create` once it runs in step 3. + for legacy_row in er.async_entries_for_config_entry( + ent_reg, legacy_entry.entry_id + ): + new_unique_id = migrate_unique_id(legacy_row.unique_id, serial) + if new_unique_id is None: + _LOGGER.warning( + "Could not migrate entity %s (unique_id=%s); leaving under legacy domain", + legacy_row.entity_id, + legacy_row.unique_id, + ) + unrecognized.append(legacy_row.entity_id) + continue + ent_reg.async_update_entity_platform( + legacy_row.entity_id, + DOMAIN, + new_config_entry_id=legacy_entry.entry_id, + new_unique_id=new_unique_id, + ) + + # Step 2: rewrite device identifiers in place. + for legacy_device in dr.async_entries_for_config_entry( + dev_reg, legacy_entry.entry_id + ): + new_identifiers = { + (DOMAIN if ident_domain == LEGACY_DOMAIN else ident_domain, ident_value) + for (ident_domain, ident_value) in legacy_device.identifiers + } + dev_reg.async_update_device( + legacy_device.id, new_identifiers=new_identifiers + ) + + # Detach unrecognized rows before removing the legacy entry so they + # aren't cascade-deleted. The notification at the bottom flags them + # for manual cleanup. + for legacy_row in er.async_entries_for_config_entry( + ent_reg, legacy_entry.entry_id + ): + ent_reg.async_update_entity( + legacy_row.entity_id, config_entry_id=None + ) + + # Step 3: stand up the new entry. If a prior run got partway through + # and crashed, an entry for this serial already exists — reuse it + # instead of creating a duplicate. + new_entry = _find_existing_chargesplit_entry(hass, serial) + if new_entry is None: + new_entry = ConfigEntry( + version=legacy_entry.version, + minor_version=legacy_entry.minor_version, + domain=DOMAIN, + title=legacy_entry.title, + data=dict(legacy_entry.data), + options=dict(legacy_entry.options), + source=SOURCE_IMPORT, + unique_id=legacy_entry.unique_id, + discovery_keys=MappingProxyType({}), + subentries_data=None, + ) + await hass.config_entries.async_add(new_entry) + # Platform setup during async_add called async_get_or_create for + # each entity and device. Those calls matched the pre-migrated rows + # by (platform, unique_id) and identifiers respectively, and updated + # their config_entry_id / config_entries to point at new_entry. + + # Step 4: remove the legacy entry. The device now has both entries + # in its config_entries set; HA will clean the legacy one out as + # part of the cascade. + await hass.config_entries.async_remove(legacy_entry.entry_id) + + migrated_count += 1 + + if migrated_count: + message = ( + f"Migrated {migrated_count} Chargesplit config " + f"entr{'y' if migrated_count == 1 else 'ies'} from the legacy " + "`Chargesplit` domain to `chargesplit`. Entity IDs were preserved, " + "so dashboards, automations, and statistics history continue to work." + ) + if unrecognized: + message += ( + "\n\nThese entities had an unrecognized unique_id format and " + "were left untouched — you may need to delete them manually:\n" + + "\n".join(f"- {eid}" for eid in unrecognized) + ) + persistent_notification.async_create( + hass, + message, + title="Chargesplit migration", + notification_id="chargesplit_legacy_migration", + ) + + +def _find_existing_chargesplit_entry( + hass: HomeAssistant, serial: str +) -> ConfigEntry | None: + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data.get("serial") == serial: + return entry + return None diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..ef4a353 --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,262 @@ +"""Integration test for the legacy-domain migration. + +Seeds the entity + device registries with the v0.0.7-shaped rows under the +old `Chargesplit` domain, runs the migration, and asserts that: + +- The old config entry is gone and a new `chargesplit` entry exists holding + the same credentials. +- Every entity row keeps its original `entity_id` (statistics history, which + is keyed on entity_id, must survive). Its `unique_id` is rewritten to the + v0.1.0 shape and its `platform` to `chargesplit`. +- The device row keeps its `device_id` but its identifiers are rewritten, + and its config-entry membership swaps to the new entry. +- A persistent_notification is created announcing the migration. +- An entity with an unrecognized unique_id is left in place (detached from + the deleted legacy entry) and called out in the notification. +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from homeassistant.components import persistent_notification +from homeassistant.helpers import device_registry as dr, entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.chargesplit.api import ChargesplitApi +from custom_components.chargesplit.migration import ( + LEGACY_DOMAIN, + async_migrate_legacy_domain, +) + +FIXTURE = Path(__file__).parent / "fixtures" / "wallbox_response.json" + + +@pytest.fixture(autouse=True) +def mock_api(): + """The migration creates new chargesplit entries; HA then runs setup on + them, which would hit the network. Patch the API to feed the fixture + instead so the coordinator's first refresh succeeds.""" + data = FIXTURE.read_bytes() + with patch.object(ChargesplitApi, "get_data", return_value=data): + yield + + +# v0.0.7 entity contract — entity_id + unique_id pairs. Derived from +# tests/test_setup.py::EXPECTED_V007 on `main`. +V007_ENTITIES = [ + # (entity_id, unique_id, domain_part, original_name) + ("sensor.chargesplit_domus_voltage_l1", "Chargesplit-TESTSERIAL-Voltage L1-TESTSERIAL", "sensor", "Voltage L1"), + ("sensor.chargesplit_domus_voltage_l2", "Chargesplit-TESTSERIAL-Voltage L2-TESTSERIAL", "sensor", "Voltage L2"), + ("sensor.chargesplit_domus_voltage_l3", "Chargesplit-TESTSERIAL-Voltage L3-TESTSERIAL", "sensor", "Voltage L3"), + ("sensor.chargesplit_domus_temperature", "Chargesplit-TESTSERIAL-Temperature-TESTSERIAL", "sensor", "Temperature"), + ("sensor.chargesplit_domus_wallbox_status", "Chargesplit-TESTSERIAL-Wallbox Status-TESTSERIAL", "sensor", "Wallbox Status"), + ("sensor.chargesplit_domus_wallbox_model", "Chargesplit-TESTSERIAL-Wallbox Model-TESTSERIAL", "sensor", "Wallbox Model"), + ("sensor.chargesplit_domus_wallbox_firmware", "Chargesplit-TESTSERIAL-Wallbox firmware-TESTSERIAL", "sensor", "Wallbox firmware"), + ("sensor.chargesplit_domus_wallbox_serial", "Chargesplit-TESTSERIAL-Wallbox serial-TESTSERIAL", "sensor", "Wallbox serial"), + ("sensor.chargesplit_domus_charged_kwh", "Chargesplit-TESTSERIAL-Charged kWh-TESTSERIAL", "sensor", "Charged kWh"), + ("sensor.chargesplit_domus_pilot_amps", "Chargesplit-TESTSERIAL-Pilot Amps-TESTSERIAL", "sensor", "Pilot Amps"), + ("sensor.chargesplit_domus_actual_amps", "Chargesplit-TESTSERIAL-Actual Amps-TESTSERIAL", "sensor", "Actual Amps"), + ("sensor.chargesplit_domus_actual_solar_power", "Chargesplit-TESTSERIAL-Actual solar power-TESTSERIAL", "sensor", "Actual solar power"), + ("sensor.chargesplit_domus_actual_house_consumption", "Chargesplit-TESTSERIAL-Actual House Consumption-TESTSERIAL", "sensor", "Actual House Consumption"), + ("sensor.chargesplit_domus_car_charging_power", "Chargesplit-TESTSERIAL-Car Charging Power-TESTSERIAL", "sensor", "Car Charging Power"), + ("sensor.chargesplit_domus_daily_house_wh", "Chargesplit-TESTSERIAL-Daily House Wh-TESTSERIAL", "sensor", "Daily House Wh"), + ("sensor.chargesplit_domus_daily_solar_wh", "Chargesplit-TESTSERIAL-Daily Solar Wh-TESTSERIAL", "sensor", "Daily Solar Wh"), + ("sensor.chargesplit_domus_schedule", "Chargesplit-TESTSERIAL-Schedule-TESTSERIAL", "sensor", "Schedule"), + ("select.chargesplit_domus_send_lock_unlock_command", "TESTSERIAL-lock_mode", "select", "Send Lock/unlock command"), + ("select.chargesplit_domus_select_chargepoint_power_amps", "TESTSERIAL-operation_mode", "select", "Select Chargepoint Power AMPS"), + ("select.chargesplit_domus_send_pause_restart_command", "TESTSERIAL-pause_mode", "select", "Send pause/restart command"), +] + +# Mapping from old unique_id → expected new unique_id, derived from the +# migration mapping. Used to assert each row migrated correctly. +EXPECTED_NEW_UNIQUE_IDS = { + "Chargesplit-TESTSERIAL-Voltage L1-TESTSERIAL": "TESTSERIAL_voltage_l1", + "Chargesplit-TESTSERIAL-Voltage L2-TESTSERIAL": "TESTSERIAL_voltage_l2", + "Chargesplit-TESTSERIAL-Voltage L3-TESTSERIAL": "TESTSERIAL_voltage_l3", + "Chargesplit-TESTSERIAL-Temperature-TESTSERIAL": "TESTSERIAL_temperature", + "Chargesplit-TESTSERIAL-Wallbox Status-TESTSERIAL": "TESTSERIAL_status", + "Chargesplit-TESTSERIAL-Wallbox Model-TESTSERIAL": "TESTSERIAL_model", + "Chargesplit-TESTSERIAL-Wallbox firmware-TESTSERIAL": "TESTSERIAL_firmware", + "Chargesplit-TESTSERIAL-Wallbox serial-TESTSERIAL": "TESTSERIAL_serial", + "Chargesplit-TESTSERIAL-Charged kWh-TESTSERIAL": "TESTSERIAL_total_charged_kwh", + "Chargesplit-TESTSERIAL-Pilot Amps-TESTSERIAL": "TESTSERIAL_pilot_amps", + "Chargesplit-TESTSERIAL-Actual Amps-TESTSERIAL": "TESTSERIAL_actual_amps", + "Chargesplit-TESTSERIAL-Actual solar power-TESTSERIAL": "TESTSERIAL_solar_power", + "Chargesplit-TESTSERIAL-Actual House Consumption-TESTSERIAL": "TESTSERIAL_house_power", + "Chargesplit-TESTSERIAL-Car Charging Power-TESTSERIAL": "TESTSERIAL_charging_power", + "Chargesplit-TESTSERIAL-Daily House Wh-TESTSERIAL": "TESTSERIAL_daily_house_wh", + "Chargesplit-TESTSERIAL-Daily Solar Wh-TESTSERIAL": "TESTSERIAL_daily_solar_wh", + "Chargesplit-TESTSERIAL-Schedule-TESTSERIAL": "TESTSERIAL_schedule", + "TESTSERIAL-lock_mode": "TESTSERIAL_lock_mode", + "TESTSERIAL-operation_mode": "TESTSERIAL_operation_mode", + "TESTSERIAL-pause_mode": "TESTSERIAL_pause_mode", +} + + +def _seed_legacy(hass): + """Add a Chargesplit-domain entry plus full v0.0.7 registry state.""" + legacy_entry = MockConfigEntry( + version=1, + domain=LEGACY_DOMAIN, + data={"serial": "TESTSERIAL", "code": "TESTSECRET"}, + title="TESTSERIAL", + ) + legacy_entry.add_to_hass(hass) + + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=legacy_entry.entry_id, + identifiers={(LEGACY_DOMAIN, "TESTSERIAL")}, + manufacturer="Chargesplit Domus", + model="WB132H", + sw_version="2.34", + name="Chargesplit Domus", + ) + + ent_reg = er.async_get(hass) + seeded = {} + for entity_id, unique_id, domain_part, original_name in V007_ENTITIES: + # Suggest the v0.0.7 entity_id explicitly. async_get_or_create + # honours suggested_object_id when nothing is registered yet. + suggested = entity_id.split(".", 1)[1] + row = ent_reg.async_get_or_create( + domain=domain_part, + platform=LEGACY_DOMAIN, + unique_id=unique_id, + suggested_object_id=suggested, + config_entry=legacy_entry, + device_id=device.id, + original_name=original_name, + ) + assert row.entity_id == entity_id, ( + f"failed to seed entity_id {entity_id}; got {row.entity_id}" + ) + seeded[unique_id] = row + return legacy_entry, device, seeded + + +async def test_migration_preserves_entity_ids_and_rewires_to_new_entry(hass): + legacy_entry, device, seeded = _seed_legacy(hass) + legacy_entry_id = legacy_entry.entry_id + + await async_migrate_legacy_domain(hass) + await hass.async_block_till_done() + + # The legacy config entry is gone. + assert hass.config_entries.async_get_entry(legacy_entry_id) is None + legacy_entries = hass.config_entries.async_entries(LEGACY_DOMAIN) + assert legacy_entries == [] + + # A new chargesplit entry exists with the same credentials. + new_entries = hass.config_entries.async_entries("chargesplit") + assert len(new_entries) == 1 + new_entry = new_entries[0] + assert new_entry.data == {"serial": "TESTSERIAL", "code": "TESTSECRET"} + assert new_entry.title == "TESTSERIAL" + + # Every seeded entity row now belongs to the new entry, with the new + # unique_id and new platform — but the entity_id is unchanged. That's + # the load-bearing assertion: statistics keep flowing. + ent_reg = er.async_get(hass) + for old_unique_id, original_row in seeded.items(): + new_unique_id = EXPECTED_NEW_UNIQUE_IDS[old_unique_id] + domain_part = original_row.entity_id.split(".", 1)[0] + migrated = ent_reg.async_get_entity_id(domain_part, "chargesplit", new_unique_id) + assert migrated is not None, ( + f"entity for new unique_id {new_unique_id} not found" + ) + assert migrated == original_row.entity_id, ( + f"entity_id changed: was {original_row.entity_id}, now {migrated}" + ) + + migrated_row = ent_reg.async_get(migrated) + assert migrated_row.platform == "chargesplit" + assert migrated_row.config_entry_id == new_entry.entry_id + + # The device row keeps its device_id, but identifiers and config-entry + # membership are swapped to the new entry. + dev_reg = dr.async_get(hass) + migrated_device = dev_reg.async_get(device.id) + assert migrated_device is not None + assert migrated_device.identifiers == {("chargesplit", "TESTSERIAL")} + assert new_entry.entry_id in migrated_device.config_entries + assert legacy_entry_id not in migrated_device.config_entries + + # User-visible notification was raised. + notifications = persistent_notification._async_get_or_create_notifications(hass) + assert "chargesplit_legacy_migration" in notifications + + +async def test_migration_is_idempotent(hass): + _seed_legacy(hass) + await async_migrate_legacy_domain(hass) + await hass.async_block_till_done() + + # Second pass: nothing left to migrate, must be a no-op. + new_entries_before = hass.config_entries.async_entries("chargesplit") + await async_migrate_legacy_domain(hass) + await hass.async_block_till_done() + new_entries_after = hass.config_entries.async_entries("chargesplit") + assert [e.entry_id for e in new_entries_before] == [ + e.entry_id for e in new_entries_after + ] + + +async def test_migration_resumes_partial_state(hass): + """If a previous attempt created the chargesplit entry but crashed before + rehoming all rows, the next pass must reuse the existing entry rather + than spawning a duplicate. + """ + legacy_entry, _device, _seeded = _seed_legacy(hass) + + # Simulate a half-done migration: a chargesplit entry already exists for + # this serial, but the legacy entry and entity rows are still in their + # old place. + existing_new = MockConfigEntry( + version=1, + domain="chargesplit", + data={"serial": "TESTSERIAL", "code": "TESTSECRET"}, + title="TESTSERIAL", + ) + existing_new.add_to_hass(hass) + existing_id = existing_new.entry_id + + await async_migrate_legacy_domain(hass) + await hass.async_block_till_done() + + new_entries = hass.config_entries.async_entries("chargesplit") + assert len(new_entries) == 1, ( + f"expected one chargesplit entry, got {[e.entry_id for e in new_entries]}" + ) + assert new_entries[0].entry_id == existing_id + + +async def test_migration_leaves_unknown_unique_id_in_place_and_notifies(hass): + legacy_entry, _device, _seeded = _seed_legacy(hass) + + ent_reg = er.async_get(hass) + weird = ent_reg.async_get_or_create( + domain="sensor", + platform=LEGACY_DOMAIN, + unique_id="Chargesplit-TESTSERIAL-Cosmic Rays-TESTSERIAL", + suggested_object_id="chargesplit_domus_cosmic_rays", + config_entry=legacy_entry, + original_name="Cosmic Rays", + ) + weird_entity_id = weird.entity_id + + await async_migrate_legacy_domain(hass) + await hass.async_block_till_done() + + # Weird entity is detached but still present. + survivor = ent_reg.async_get(weird_entity_id) + assert survivor is not None + assert survivor.config_entry_id is None + assert survivor.platform == LEGACY_DOMAIN + + # Notification mentions it. + notifications = persistent_notification._async_get_or_create_notifications(hass) + assert "chargesplit_legacy_migration" in notifications + assert weird_entity_id in notifications["chargesplit_legacy_migration"]["message"] diff --git a/tests/test_migration_mapping.py b/tests/test_migration_mapping.py new file mode 100644 index 0000000..ae6fa44 --- /dev/null +++ b/tests/test_migration_mapping.py @@ -0,0 +1,76 @@ +"""Unit tests for migrate_unique_id — the pure-function part of the migration.""" + +import pytest + +from custom_components.chargesplit.migration import migrate_unique_id + + +SERIAL = "TESTSERIAL" + + +# Every (old_unique_id, expected_new_unique_id) pair derivable from the +# v0.0.7 contract (see tests/test_setup.py EXPECTED_V007 on `main`). +SENSOR_CASES = [ + ("Chargesplit-TESTSERIAL-Voltage L1-TESTSERIAL", "TESTSERIAL_voltage_l1"), + ("Chargesplit-TESTSERIAL-Voltage L2-TESTSERIAL", "TESTSERIAL_voltage_l2"), + ("Chargesplit-TESTSERIAL-Voltage L3-TESTSERIAL", "TESTSERIAL_voltage_l3"), + ("Chargesplit-TESTSERIAL-Temperature-TESTSERIAL", "TESTSERIAL_temperature"), + ("Chargesplit-TESTSERIAL-Wallbox Status-TESTSERIAL", "TESTSERIAL_status"), + ("Chargesplit-TESTSERIAL-Wallbox Model-TESTSERIAL", "TESTSERIAL_model"), + ("Chargesplit-TESTSERIAL-Wallbox firmware-TESTSERIAL", "TESTSERIAL_firmware"), + ("Chargesplit-TESTSERIAL-Wallbox serial-TESTSERIAL", "TESTSERIAL_serial"), + ("Chargesplit-TESTSERIAL-Charged kWh-TESTSERIAL", "TESTSERIAL_total_charged_kwh"), + ("Chargesplit-TESTSERIAL-Pilot Amps-TESTSERIAL", "TESTSERIAL_pilot_amps"), + ("Chargesplit-TESTSERIAL-Actual Amps-TESTSERIAL", "TESTSERIAL_actual_amps"), + ("Chargesplit-TESTSERIAL-Actual solar power-TESTSERIAL", "TESTSERIAL_solar_power"), + ("Chargesplit-TESTSERIAL-Actual House Consumption-TESTSERIAL", "TESTSERIAL_house_power"), + ("Chargesplit-TESTSERIAL-Car Charging Power-TESTSERIAL", "TESTSERIAL_charging_power"), + ("Chargesplit-TESTSERIAL-Daily House Wh-TESTSERIAL", "TESTSERIAL_daily_house_wh"), + ("Chargesplit-TESTSERIAL-Daily Solar Wh-TESTSERIAL", "TESTSERIAL_daily_solar_wh"), + ("Chargesplit-TESTSERIAL-Schedule-TESTSERIAL", "TESTSERIAL_schedule"), +] + +SELECT_CASES = [ + ("TESTSERIAL-operation_mode", "TESTSERIAL_operation_mode"), + ("TESTSERIAL-lock_mode", "TESTSERIAL_lock_mode"), + ("TESTSERIAL-pause_mode", "TESTSERIAL_pause_mode"), +] + + +@pytest.mark.parametrize("old, new", SENSOR_CASES + SELECT_CASES) +def test_recognized(old, new): + assert migrate_unique_id(old, SERIAL) == new + + +@pytest.mark.parametrize( + "old", + [ + # Already in the new format — not a v0.0.x id, leave it alone. + "TESTSERIAL_voltage_l1", + # Wrong serial. + "Chargesplit-OTHERSERIAL-Voltage L1-OTHERSERIAL", + # Unknown description — fail closed. + "Chargesplit-TESTSERIAL-Cosmic Rays-TESTSERIAL", + # Garbage. + "", + "TESTSERIAL", + ], +) +def test_unrecognized(old): + assert migrate_unique_id(old, SERIAL) is None + + +def test_serial_with_dash_is_parsed_safely(): + # Hyphenated serials are anchored end-to-end; the description in the + # middle still resolves cleanly. + serial = "ABC-123" + assert ( + migrate_unique_id( + f"Chargesplit-{serial}-Voltage L1-{serial}", serial + ) + == f"{serial}_voltage_l1" + ) + assert ( + migrate_unique_id(f"{serial}-operation_mode", serial) + == f"{serial}_operation_mode" + ) diff --git a/tests/test_setup.py b/tests/test_setup.py deleted file mode 100644 index 28f00c9..0000000 --- a/tests/test_setup.py +++ /dev/null @@ -1,383 +0,0 @@ -"""v0.0.7 setup contract. - -Captures the entity registry, device registry, and a few state-value -spot checks produced by the v0.0.7 codebase against a scrubbed fixture. - -The v0.1.0 migration test on the pr-9 branch should NOT re-use this -pattern verbatim. The right shape for that test is: pre-populate the -entity + device registries with the EXPECTED_V007 / EXPECTED_DEVICES -rows below, THEN call async_setup under v0.1.0 code, THEN assert -nothing was renamed, orphaned, or had its statistics-keying attributes -flipped (state_class, unit_of_measurement, device_class). Setting up a -fresh entry under v0.1.0 doesn't exercise migration. -""" - -from operator import itemgetter -from pathlib import Path -from unittest.mock import patch - -from homeassistant.helpers import device_registry as dr, entity_registry as er -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.Chargesplit.api import ChargesplitApi - -FIXTURE = Path(__file__).parent / "fixtures" / "wallbox_response.json" - - -def _entity_snapshot(hass, e): - state = hass.states.get(e.entity_id) - attrs = state.attributes if state else {} - return { - "entity_id": e.entity_id, - "unique_id": e.unique_id, - "platform": e.platform, - "original_name": e.original_name, - "entity_category": e.entity_category.value if e.entity_category else None, - "disabled_by": e.disabled_by.value if e.disabled_by else None, - "has_entity_name": e.has_entity_name, - "device_class": attrs.get("device_class"), - "unit_of_measurement": attrs.get("unit_of_measurement"), - "state_class": attrs.get("state_class"), - } - - -def _device_snapshot(d): - return { - "identifiers": sorted(d.identifiers), - "manufacturer": d.manufacturer, - "model": d.model, - "sw_version": d.sw_version, - "name": d.name, - } - - -async def test_setup_produces_correct_entities(hass): - data = FIXTURE.read_bytes() - - entry = MockConfigEntry( - version=1, - domain="Chargesplit", - data={"serial": "TESTSERIAL", "code": "TESTSECRET"}, - title="TESTSERIAL", - ) - entry.add_to_hass(hass) - - with patch.object(ChargesplitApi, "get_data", return_value=data): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - registry_entries = [ - e for e in entity_registry.entities.values() - if e.config_entry_id == entry.entry_id - ] - entries = [_entity_snapshot(hass, e) for e in registry_entries] - - device_registry = dr.async_get(hass) - devices = [ - _device_snapshot(d) - for d in device_registry.devices.values() - if entry.entry_id in d.config_entries - ] - - # All entities attach to a single device. Implicit in EXPECTED_DEVICES - # having one row, but explicit is clearer and gives a better error if a - # future change accidentally splits entities across devices. - device_ids = {e.device_id for e in registry_entries} - assert len(device_ids) == 1, f"expected entities to share one device, got {device_ids}" - - assert sorted(entries, key=itemgetter("unique_id")) == sorted( - EXPECTED_V007, key=itemgetter("unique_id") - ) - assert sorted(devices, key=itemgetter("identifiers")) == sorted( - EXPECTED_DEVICES, key=itemgetter("identifiers") - ) - - # State-value assertions: every sensor's JSON-key -> state wiring is locked. - for entity_id, expected in EXPECTED_STATES.items(): - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} did not register" - assert state.state == expected, f"{entity_id}: expected {expected!r}, got {state.state!r}" - - -EXPECTED_V007 = [ - { - "entity_id": "sensor.chargesplit_domus_actual_amps", - "unique_id": "Chargesplit-TESTSERIAL-Actual Amps-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Actual Amps", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "current", - "unit_of_measurement": "A", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_actual_house_consumption", - "unique_id": "Chargesplit-TESTSERIAL-Actual House Consumption-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Actual House Consumption", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "power", - "unit_of_measurement": "kW", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_actual_solar_power", - "unique_id": "Chargesplit-TESTSERIAL-Actual solar power-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Actual solar power", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "power", - "unit_of_measurement": "kW", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_car_charging_power", - "unique_id": "Chargesplit-TESTSERIAL-Car Charging Power-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Car Charging Power", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "power", - "unit_of_measurement": "kW", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_charged_kwh", - "unique_id": "Chargesplit-TESTSERIAL-Charged kWh-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Charged kWh", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "energy", - "unit_of_measurement": "kWh", - "state_class": "total_increasing", - }, - { - "entity_id": "sensor.chargesplit_domus_daily_house_wh", - "unique_id": "Chargesplit-TESTSERIAL-Daily House Wh-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Daily House Wh", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "energy", - "unit_of_measurement": "Wh", - "state_class": "total_increasing", - }, - { - "entity_id": "sensor.chargesplit_domus_daily_solar_wh", - "unique_id": "Chargesplit-TESTSERIAL-Daily Solar Wh-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Daily Solar Wh", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "energy", - "unit_of_measurement": "Wh", - "state_class": "total_increasing", - }, - { - "entity_id": "sensor.chargesplit_domus_pilot_amps", - "unique_id": "Chargesplit-TESTSERIAL-Pilot Amps-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Pilot Amps", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "current", - "unit_of_measurement": "A", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_schedule", - "unique_id": "Chargesplit-TESTSERIAL-Schedule-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Schedule", - "entity_category": "diagnostic", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "sensor.chargesplit_domus_temperature", - "unique_id": "Chargesplit-TESTSERIAL-Temperature-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Temperature", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "temperature", - "unit_of_measurement": "°C", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_voltage_l1", - "unique_id": "Chargesplit-TESTSERIAL-Voltage L1-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Voltage L1", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "voltage", - "unit_of_measurement": "V", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_voltage_l2", - "unique_id": "Chargesplit-TESTSERIAL-Voltage L2-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Voltage L2", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "voltage", - "unit_of_measurement": "V", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_voltage_l3", - "unique_id": "Chargesplit-TESTSERIAL-Voltage L3-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Voltage L3", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": "voltage", - "unit_of_measurement": "V", - "state_class": "measurement", - }, - { - "entity_id": "sensor.chargesplit_domus_wallbox_model", - "unique_id": "Chargesplit-TESTSERIAL-Wallbox Model-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Wallbox Model", - "entity_category": "diagnostic", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "sensor.chargesplit_domus_wallbox_status", - "unique_id": "Chargesplit-TESTSERIAL-Wallbox Status-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Wallbox Status", - "entity_category": None, - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "sensor.chargesplit_domus_wallbox_firmware", - "unique_id": "Chargesplit-TESTSERIAL-Wallbox firmware-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Wallbox firmware", - "entity_category": "diagnostic", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "sensor.chargesplit_domus_wallbox_serial", - "unique_id": "Chargesplit-TESTSERIAL-Wallbox serial-TESTSERIAL", - "platform": "Chargesplit", - "original_name": "Wallbox serial", - "entity_category": "diagnostic", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "select.chargesplit_domus_send_lock_unlock_command", - "unique_id": "TESTSERIAL-lock_mode", - "platform": "Chargesplit", - "original_name": "Send Lock/unlock command", - "entity_category": "config", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "select.chargesplit_domus_select_chargepoint_power_amps", - "unique_id": "TESTSERIAL-operation_mode", - "platform": "Chargesplit", - "original_name": "Select Chargepoint Power AMPS", - "entity_category": "config", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, - { - "entity_id": "select.chargesplit_domus_send_pause_restart_command", - "unique_id": "TESTSERIAL-pause_mode", - "platform": "Chargesplit", - "original_name": "Send pause/restart command", - "entity_category": "config", - "disabled_by": None, - "has_entity_name": False, - "device_class": None, - "unit_of_measurement": None, - "state_class": None, - }, -] - -EXPECTED_DEVICES = [ - { - "identifiers": [("Chargesplit", "TESTSERIAL")], - "manufacturer": "Chargesplit Domus", - "model": "WB132H", - "sw_version": "2.34", - "name": "Chargesplit Domus", - }, -] - -EXPECTED_STATES = { - # TODO(v0.0.8): The next five suffer from type-passthrough. - # HOUSEPWR/SOLARPWR/CHARGINGPWR/TOTALCHARGED come from the wallbox API - # as JSON strings even at rest; AMP is an int in this fixture but is - # expected to return as a string during active charging (the fixture - # only captures the resting state). The sensor doesn't cast, so HA's - # recorder coerces via float() for stats and logs a warning every - # cycle. v0.0.8 should cast in the coordinator. When that lands, - # "0.00" becomes "0.0" here; AMP's "0" may also shift to "0.0". - "sensor.chargesplit_domus_actual_amps": "0", - "sensor.chargesplit_domus_actual_house_consumption": "0.52", - "sensor.chargesplit_domus_actual_solar_power": "0.52", - "sensor.chargesplit_domus_car_charging_power": "0.00", - "sensor.chargesplit_domus_charged_kwh": "0.00", - # DAYHOUSE in the fixture is 5450.409999999997; HA rounds for display/stats. - "sensor.chargesplit_domus_daily_house_wh": "5450.41", - "sensor.chargesplit_domus_daily_solar_wh": "0", - "sensor.chargesplit_domus_pilot_amps": "25", - "sensor.chargesplit_domus_schedule": "1", - "sensor.chargesplit_domus_temperature": "21.2", - "sensor.chargesplit_domus_voltage_l1": "0", - "sensor.chargesplit_domus_voltage_l2": "0", - "sensor.chargesplit_domus_voltage_l3": "0", - "sensor.chargesplit_domus_wallbox_firmware": "2.34", - "sensor.chargesplit_domus_wallbox_model": "WB132H", - "sensor.chargesplit_domus_wallbox_serial": "TESTSERIAL", - "sensor.chargesplit_domus_wallbox_status": "SCHEDULE", -} diff --git a/tests/test_setup_contract.py b/tests/test_setup_contract.py new file mode 100644 index 0000000..a67b7cc --- /dev/null +++ b/tests/test_setup_contract.py @@ -0,0 +1,375 @@ +"""v0.1.0 setup contract. + +Captures the entity registry, device registry, and state-value spot checks +produced by v0.1.0 on a *fresh* install against the scrubbed fixture. + +This is the post-rename baseline. The companion migration test in +`test_migration.py` covers what existing v0.0.x installs end up with after +auto-migration — and intentionally diverges from this contract on the +entity_id field, because migration preserves the v0.0.7 entity_ids +(statistics history is keyed on entity_id) while a fresh install computes +new ones from `has_entity_name=True` + the v0.1.0 names. +""" + +from operator import itemgetter +from pathlib import Path +from unittest.mock import patch + +from homeassistant.helpers import device_registry as dr, entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.chargesplit.api import ChargesplitApi + +FIXTURE = Path(__file__).parent / "fixtures" / "wallbox_response.json" + + +def _entity_snapshot(hass, e): + state = hass.states.get(e.entity_id) + attrs = state.attributes if state else {} + return { + "entity_id": e.entity_id, + "unique_id": e.unique_id, + "platform": e.platform, + "original_name": e.original_name, + "entity_category": e.entity_category.value if e.entity_category else None, + "disabled_by": e.disabled_by.value if e.disabled_by else None, + "has_entity_name": e.has_entity_name, + "device_class": attrs.get("device_class"), + "unit_of_measurement": attrs.get("unit_of_measurement"), + "state_class": attrs.get("state_class"), + } + + +def _device_snapshot(d): + return { + "identifiers": sorted(d.identifiers), + "manufacturer": d.manufacturer, + "model": d.model, + "sw_version": d.sw_version, + "name": d.name, + } + + +async def test_v010_fresh_install_contract(hass): + data = FIXTURE.read_bytes() + + entry = MockConfigEntry( + version=1, + domain="chargesplit", + data={"serial": "TESTSERIAL", "code": "TESTSECRET"}, + title="TESTSERIAL", + ) + entry.add_to_hass(hass) + + with patch.object(ChargesplitApi, "get_data", return_value=data): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + registry_entries = [ + e + for e in entity_registry.entities.values() + if e.config_entry_id == entry.entry_id + ] + entries = [_entity_snapshot(hass, e) for e in registry_entries] + + device_registry = dr.async_get(hass) + devices = [ + _device_snapshot(d) + for d in device_registry.devices.values() + if entry.entry_id in d.config_entries + ] + + device_ids = {e.device_id for e in registry_entries} + assert ( + len(device_ids) == 1 + ), f"expected entities to share one device, got {device_ids}" + + assert sorted(entries, key=itemgetter("unique_id")) == sorted( + EXPECTED_V010, key=itemgetter("unique_id") + ) + assert sorted(devices, key=itemgetter("identifiers")) == sorted( + EXPECTED_DEVICES, key=itemgetter("identifiers") + ) + + for entity_id, expected in EXPECTED_STATES.items(): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} did not register" + assert ( + state.state == expected + ), f"{entity_id}: expected {expected!r}, got {state.state!r}" + + +EXPECTED_V010 = [ + { + "entity_id": "sensor.chargesplit_testserial_actual_amps", + "unique_id": "TESTSERIAL_actual_amps", + "platform": "chargesplit", + "original_name": "Actual Amps", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "current", + "unit_of_measurement": "A", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_charging_power", + "unique_id": "TESTSERIAL_charging_power", + "platform": "chargesplit", + "original_name": "Charging Power", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "power", + "unit_of_measurement": "kW", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_daily_house_energy", + "unique_id": "TESTSERIAL_daily_house_wh", + "platform": "chargesplit", + "original_name": "Daily House Energy", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "energy", + "unit_of_measurement": "Wh", + "state_class": "total_increasing", + }, + { + "entity_id": "sensor.chargesplit_testserial_daily_solar_energy", + "unique_id": "TESTSERIAL_daily_solar_wh", + "platform": "chargesplit", + "original_name": "Daily Solar Energy", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "energy", + "unit_of_measurement": "Wh", + "state_class": "total_increasing", + }, + { + "entity_id": "sensor.chargesplit_testserial_firmware", + "unique_id": "TESTSERIAL_firmware", + "platform": "chargesplit", + "original_name": "Firmware", + "entity_category": "diagnostic", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "sensor.chargesplit_testserial_house_consumption", + "unique_id": "TESTSERIAL_house_power", + "platform": "chargesplit", + "original_name": "House Consumption", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "power", + "unit_of_measurement": "kW", + "state_class": "measurement", + }, + { + "entity_id": "select.chargesplit_testserial_lock", + "unique_id": "TESTSERIAL_lock_mode", + "platform": "chargesplit", + "original_name": "Lock", + "entity_category": "config", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "sensor.chargesplit_testserial_model", + "unique_id": "TESTSERIAL_model", + "platform": "chargesplit", + "original_name": "Model", + "entity_category": "diagnostic", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "select.chargesplit_testserial_power_limit", + "unique_id": "TESTSERIAL_operation_mode", + "platform": "chargesplit", + "original_name": "Power Limit", + "entity_category": "config", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "select.chargesplit_testserial_pause", + "unique_id": "TESTSERIAL_pause_mode", + "platform": "chargesplit", + "original_name": "Pause", + "entity_category": "config", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "sensor.chargesplit_testserial_pilot_amps", + "unique_id": "TESTSERIAL_pilot_amps", + "platform": "chargesplit", + "original_name": "Pilot Amps", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "current", + "unit_of_measurement": "A", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_schedule", + "unique_id": "TESTSERIAL_schedule", + "platform": "chargesplit", + "original_name": "Schedule", + "entity_category": "diagnostic", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "sensor.chargesplit_testserial_serial", + "unique_id": "TESTSERIAL_serial", + "platform": "chargesplit", + "original_name": "Serial", + "entity_category": "diagnostic", + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "sensor.chargesplit_testserial_solar_power", + "unique_id": "TESTSERIAL_solar_power", + "platform": "chargesplit", + "original_name": "Solar Power", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "power", + "unit_of_measurement": "kW", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_wallbox_status", + "unique_id": "TESTSERIAL_status", + "platform": "chargesplit", + "original_name": "Wallbox Status", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": None, + "unit_of_measurement": None, + "state_class": None, + }, + { + "entity_id": "sensor.chargesplit_testserial_temperature", + "unique_id": "TESTSERIAL_temperature", + "platform": "chargesplit", + "original_name": "Temperature", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "temperature", + "unit_of_measurement": "°C", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_total_charged", + "unique_id": "TESTSERIAL_total_charged_kwh", + "platform": "chargesplit", + "original_name": "Total Charged", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + }, + { + "entity_id": "sensor.chargesplit_testserial_voltage_l1", + "unique_id": "TESTSERIAL_voltage_l1", + "platform": "chargesplit", + "original_name": "Voltage L1", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "voltage", + "unit_of_measurement": "V", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_voltage_l2", + "unique_id": "TESTSERIAL_voltage_l2", + "platform": "chargesplit", + "original_name": "Voltage L2", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "voltage", + "unit_of_measurement": "V", + "state_class": "measurement", + }, + { + "entity_id": "sensor.chargesplit_testserial_voltage_l3", + "unique_id": "TESTSERIAL_voltage_l3", + "platform": "chargesplit", + "original_name": "Voltage L3", + "entity_category": None, + "disabled_by": None, + "has_entity_name": True, + "device_class": "voltage", + "unit_of_measurement": "V", + "state_class": "measurement", + }, +] + +EXPECTED_DEVICES = [ + { + "identifiers": [("chargesplit", "TESTSERIAL")], + "manufacturer": "Chargesplit", + "model": "WB132H", + "sw_version": "2.34", + "name": "Chargesplit TESTSERIAL", + }, +] + +EXPECTED_STATES = { + "sensor.chargesplit_testserial_voltage_l1": "0", + "sensor.chargesplit_testserial_voltage_l2": "0", + "sensor.chargesplit_testserial_voltage_l3": "0", + "sensor.chargesplit_testserial_temperature": "21.2", + "sensor.chargesplit_testserial_wallbox_status": "SCHEDULE", + "sensor.chargesplit_testserial_model": "WB132H", + "sensor.chargesplit_testserial_firmware": "2.34", + "sensor.chargesplit_testserial_serial": "TESTSERIAL", + "sensor.chargesplit_testserial_total_charged": "0.00", + "sensor.chargesplit_testserial_pilot_amps": "25", + "sensor.chargesplit_testserial_actual_amps": "0", + "sensor.chargesplit_testserial_solar_power": "0.52", + "sensor.chargesplit_testserial_house_consumption": "0.52", + "sensor.chargesplit_testserial_charging_power": "0.00", + "sensor.chargesplit_testserial_daily_house_energy": "5450.41", + "sensor.chargesplit_testserial_daily_solar_energy": "0", + "sensor.chargesplit_testserial_schedule": "1", + "select.chargesplit_testserial_power_limit": "25", +} From 4164eebbcf201f800320fdb537c35f3e59383404 Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 13 May 2026 22:14:27 +0200 Subject: [PATCH 5/8] Add placeholder brand icons for the legacy Chargesplit --- custom_components/Chargesplit/brand/icon.png | Bin 0 -> 16149 bytes custom_components/Chargesplit/brand/icon@2x.png | Bin 0 -> 45192 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 custom_components/Chargesplit/brand/icon.png create mode 100644 custom_components/Chargesplit/brand/icon@2x.png diff --git a/custom_components/Chargesplit/brand/icon.png b/custom_components/Chargesplit/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..812d109d65be5ffd34806d5c47ab6fd3cb9bdd4a GIT binary patch literal 16149 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 MQc0p#+&K9E0V#yr>;M1& literal 0 HcmV?d00001 diff --git a/custom_components/Chargesplit/brand/icon@2x.png b/custom_components/Chargesplit/brand/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..cb67c940ff5854e1eba2ff477c1cbd664f92edd3 GIT binary patch literal 45192 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

j5&?SF(r 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 Date: Wed, 13 May 2026 22:17:41 +0200 Subject: [PATCH 6/8] Simplify migration: shim drives per-entry, no reentry guard, rename folder for HACS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes: 1. Migration moves from `chargesplit.async_setup` (bulk, over all legacy entries at integration setup) to `chargesplit_legacy_shim.async_setup_entry` (per-entry, called naturally by HA for each orphaned entry it finds). `async_migrate_legacy_domain` collapses to `async_migrate_entry`. The reentry guard is gone — the shim's setup_entry can't recurse into itself the way `chargesplit.async_setup` could when its own `async_add` re-entered the integration's setup. The migration now also schedules its own follow-up `async_remove(legacy_entry.entry_id)` via `async_create_task` (we can't `await` it synchronously from inside the entry's own setup — that would deadlock on setup_lock). 2. The legacy shim folder renames from `custom_components/Chargesplit/` to `custom_components/chargesplit_legacy_shim/`. HA looks up integrations by the `domain` field in `manifest.json`, not by folder name — the shim's manifest still declares `domain: "Chargesplit"`, so orphaned entries continue to resolve to it. Folder name change is purely to fix HACS validation: HACS scans `custom_components/*/` alphabetically and treats the first hit as the canonical integration for brand-asset checks; `Chargesplit` (uppercase C, 67) sorted before `chargesplit` (lowercase c, 99), so HACS was looking for brand assets on the *shim* and reporting "no brand assets" even though the real integration at `chargesplit/` had them. The new name sorts after `chargesplit` so HACS picks the right folder. 3. `chargesplit/__init__.py` loses its `async_setup` hook entirely — the migration no longer lives there. Net shape: - `chargesplit_legacy_shim/__init__.py`: ~10 lines of logic — import `async_migrate_entry` from chargesplit, call it, return True. - `chargesplit/migration.py`: still hosts `async_migrate_entry` and the mapping table. Per-entry, no bulk loop, no reentry guard. - `chargesplit/__init__.py`: back to its pre-migration shape. Tests updated for the new entry point. Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/Chargesplit/__init__.py | 35 --- custom_components/chargesplit/__init__.py | 6 - custom_components/chargesplit/migration.py | 263 ++++++++---------- .../chargesplit_legacy_shim/__init__.py | 38 +++ .../brand/icon.png | Bin .../brand/icon@2x.png | Bin .../manifest.json | 0 tests/test_migration.py | 33 +-- 8 files changed, 165 insertions(+), 210 deletions(-) delete mode 100644 custom_components/Chargesplit/__init__.py create mode 100644 custom_components/chargesplit_legacy_shim/__init__.py rename custom_components/{Chargesplit => chargesplit_legacy_shim}/brand/icon.png (100%) rename custom_components/{Chargesplit => chargesplit_legacy_shim}/brand/icon@2x.png (100%) rename custom_components/{Chargesplit => chargesplit_legacy_shim}/manifest.json (100%) diff --git a/custom_components/Chargesplit/__init__.py b/custom_components/Chargesplit/__init__.py deleted file mode 100644 index 2b7fcf2..0000000 --- a/custom_components/Chargesplit/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Legacy `Chargesplit` (capitalized) domain shim. - -v0.0.x used DOMAIN="Chargesplit". v0.1.0 renamed to lowercase. Existing -installs have orphaned config entries under the old capitalized domain; -this shim exists only so HA can resolve those entries to *something* on -disk, which in turn forces `chargesplit` (our real integration, declared -as a dependency in this shim's manifest) to load. `chargesplit.async_setup` -then runs the migration that rehomes the entry under the lowercase domain. - -Once an install has been migrated, no `Chargesplit` config entries remain -and this shim is never loaded again. It can be deleted from the release -once we're confident nobody is upgrading from <0.1.0. -""" - -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - # An entry only reaches us if migration didn't remove it during - # `chargesplit.async_setup`. The most likely cause is an unrecognized - # entity unique_id; the migration logs and notifies in that case. - # We return True so HA marks the entry LOADED rather than erroring — - # without this the entry stays in setup_error and confuses the UI. - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - return True diff --git a/custom_components/chargesplit/__init__.py b/custom_components/chargesplit/__init__.py index eb07ca2..664bd1c 100644 --- a/custom_components/chargesplit/__init__.py +++ b/custom_components/chargesplit/__init__.py @@ -14,7 +14,6 @@ DOMAIN, ) from .coordinator import ChargesplitDataUpdateCoordinator -from .migration import async_migrate_legacy_domain PLATFORMS = [Platform.SENSOR, Platform.SELECT] @@ -23,11 +22,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - await async_migrate_legacy_domain(hass) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/chargesplit/migration.py b/custom_components/chargesplit/migration.py index 80b2c65..c10c3ae 100644 --- a/custom_components/chargesplit/migration.py +++ b/custom_components/chargesplit/migration.py @@ -5,19 +5,18 @@ requiring all-lowercase domains, so v0.1.0 renamed to `chargesplit`. Existing installs end up with orphaned entries: the old config entry, all entity registry rows (platform="Chargesplit", verbose unique_ids), and the device -row (identifiers={("Chargesplit", serial)}) are pointed at an integration -that no longer exists on disk. +row (identifiers={("Chargesplit", serial)}) all point at an integration that +no longer exists on disk. This module rewrites that state in-place so entity_ids stay stable — preserving long-term-statistics history (keyed by entity_id), dashboards, and automations. -The migration runs from `async_setup` on the new `chargesplit` domain. For -that to fire at all when only orphaned `Chargesplit` entries exist, the -sibling `custom_components/Chargesplit/` shim manifest declares -`dependencies: ["chargesplit"]` — HA loads the shim because the orphaned -entry references it, and the dependency forces `chargesplit.async_setup` to -run first. +Trigger. The sibling `custom_components/Chargesplit/` shim declares +`dependencies: ["chargesplit"]`. HA loads the shim because legacy entries +reference it, and the dependency makes it load `chargesplit` first. The +shim's `async_setup_entry` then calls `async_migrate_entry` for each +legacy entry; that function is the only entry point in this module. """ from __future__ import annotations @@ -83,168 +82,126 @@ def migrate_unique_id(old: str, serial: str) -> str | None: return None -_REENTRY_GUARD_KEY = "_chargesplit_migration_in_progress" +async def async_migrate_entry(hass: HomeAssistant, legacy_entry: ConfigEntry) -> None: + """Migrate one orphaned `Chargesplit` config entry to `chargesplit`. + Sequencing (all public API): -async def async_migrate_legacy_domain(hass: HomeAssistant) -> None: - """Rewrite orphaned `Chargesplit` config + registry rows to `chargesplit`. - - Idempotent: safe to call repeatedly. If interrupted mid-way and re-run, - it picks up the partially-migrated state (new chargesplit entry already - exists for the serial) instead of duplicating. - - Sequencing rationale (all public API): - - 1. Rewrite legacy entity rows: change `platform` to `chargesplit` and - `unique_id` to the v0.1.0 shape, but leave `config_entry_id` pointing - at the legacy entry. The new entry doesn't exist yet, and that's - fine — `async_update_entity_platform` accepts a same-value - `new_config_entry_id` (it's just gating against accidental orphaning - when the entity is already linked). - 2. Rewrite the device's identifier tuple. Leave config-entry membership - alone. + 1. Rewrite the legacy entity rows in place: new `platform=chargesplit` + and new `unique_id`, but `new_config_entry_id=legacy_entry.entry_id` + (the gating value, not a real change — `async_update_entity_platform` + refuses UNDEFINED when a row is already linked). + 2. Rewrite the device's identifier tuple, leaving config-entry + membership alone. 3. `async_add` the new entry. Its platform setup calls - `async_get_or_create(domain, "chargesplit", new_unique_id)` and - `async_get_or_create(... identifiers=...)`, which match the rows - we pre-migrated in steps 1-2 and re-link them to the new entry as - a side effect of `_async_update_entity` / `_async_update_device`. - 4. Remove the legacy entry. The new entry is already in the device's - config_entries set; HA cascade-clears the legacy id during removal. - - Re-entry guard: `async_add` in step 3 awaits the new entry's setup. If - `chargesplit` hasn't been loaded yet (the typical bootstrap order has - HA load us first via the legacy-domain shim's `dependencies`, but tests - and edge cases can invert this), HA loads it inline, which calls - `chargesplit.async_setup` again, which calls *this* function again. - The inner call would complete the migration first; the outer call would - then trip over an already-removed legacy entry. The hass.data flag - below short-circuits the inner call instead. + `async_get_or_create(... platform, unique_id)` and + `async_get_or_create(... identifiers=...)`, which match the + pre-migrated rows and adopt them — `_async_update_entity` / + `_async_update_device` set `config_entry_id` to the new entry as + a side effect. + 4. Schedule the legacy entry's removal as a follow-up task. We can't + `await async_remove(legacy_entry.entry_id)` synchronously: this + function runs inside the shim's `async_setup_entry`, which holds + the legacy entry's setup_lock; `async_remove` would try to acquire + the same lock and deadlock. `hass.async_create_task` defers it + past the current setup, where the lock is no longer held. + + Idempotent: if a previous attempt crashed after step 3 but before + step 4, the next pass re-runs steps 1-2 as no-ops on the already- + migrated rows, finds the existing chargesplit entry in step 3 and + reuses it, and re-schedules removal in step 4. """ - if hass.data.get(_REENTRY_GUARD_KEY): - return - - legacy_entries = list(hass.config_entries.async_entries(LEGACY_DOMAIN)) - if not legacy_entries: + serial = legacy_entry.data.get("serial") + if not serial: + _LOGGER.warning( + "Legacy Chargesplit entry %s has no serial in data; skipping", + legacy_entry.entry_id, + ) return - hass.data[_REENTRY_GUARD_KEY] = True - try: - await _do_migrate(hass, legacy_entries) - finally: - hass.data.pop(_REENTRY_GUARD_KEY, None) - - -async def _do_migrate( - hass: HomeAssistant, legacy_entries: list[ConfigEntry] -) -> None: - ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) - migrated_count = 0 unrecognized: list[str] = [] - for legacy_entry in legacy_entries: - serial = legacy_entry.data.get("serial") - if not serial: + # Step 1: rewrite entity rows in place. + for legacy_row in er.async_entries_for_config_entry( + ent_reg, legacy_entry.entry_id + ): + new_unique_id = migrate_unique_id(legacy_row.unique_id, serial) + if new_unique_id is None: _LOGGER.warning( - "Legacy Chargesplit entry %s has no serial in data; skipping", - legacy_entry.entry_id, - ) - continue - - # Step 1: rewrite entity rows. Keep them pointed at the legacy entry - # for now; the new entry's platform setup will adopt them via - # `async_get_or_create` once it runs in step 3. - for legacy_row in er.async_entries_for_config_entry( - ent_reg, legacy_entry.entry_id - ): - new_unique_id = migrate_unique_id(legacy_row.unique_id, serial) - if new_unique_id is None: - _LOGGER.warning( - "Could not migrate entity %s (unique_id=%s); leaving under legacy domain", - legacy_row.entity_id, - legacy_row.unique_id, - ) - unrecognized.append(legacy_row.entity_id) - continue - ent_reg.async_update_entity_platform( + "Could not migrate entity %s (unique_id=%s); leaving under legacy domain", legacy_row.entity_id, - DOMAIN, - new_config_entry_id=legacy_entry.entry_id, - new_unique_id=new_unique_id, - ) - - # Step 2: rewrite device identifiers in place. - for legacy_device in dr.async_entries_for_config_entry( - dev_reg, legacy_entry.entry_id - ): - new_identifiers = { - (DOMAIN if ident_domain == LEGACY_DOMAIN else ident_domain, ident_value) - for (ident_domain, ident_value) in legacy_device.identifiers - } - dev_reg.async_update_device( - legacy_device.id, new_identifiers=new_identifiers - ) - - # Detach unrecognized rows before removing the legacy entry so they - # aren't cascade-deleted. The notification at the bottom flags them - # for manual cleanup. - for legacy_row in er.async_entries_for_config_entry( - ent_reg, legacy_entry.entry_id - ): - ent_reg.async_update_entity( - legacy_row.entity_id, config_entry_id=None - ) - - # Step 3: stand up the new entry. If a prior run got partway through - # and crashed, an entry for this serial already exists — reuse it - # instead of creating a duplicate. - new_entry = _find_existing_chargesplit_entry(hass, serial) - if new_entry is None: - new_entry = ConfigEntry( - version=legacy_entry.version, - minor_version=legacy_entry.minor_version, - domain=DOMAIN, - title=legacy_entry.title, - data=dict(legacy_entry.data), - options=dict(legacy_entry.options), - source=SOURCE_IMPORT, - unique_id=legacy_entry.unique_id, - discovery_keys=MappingProxyType({}), - subentries_data=None, + legacy_row.unique_id, ) - await hass.config_entries.async_add(new_entry) - # Platform setup during async_add called async_get_or_create for - # each entity and device. Those calls matched the pre-migrated rows - # by (platform, unique_id) and identifiers respectively, and updated - # their config_entry_id / config_entries to point at new_entry. - - # Step 4: remove the legacy entry. The device now has both entries - # in its config_entries set; HA will clean the legacy one out as - # part of the cascade. - await hass.config_entries.async_remove(legacy_entry.entry_id) + unrecognized.append(legacy_row.entity_id) + continue + ent_reg.async_update_entity_platform( + legacy_row.entity_id, + DOMAIN, + new_config_entry_id=legacy_entry.entry_id, + new_unique_id=new_unique_id, + ) - migrated_count += 1 + # Step 2: rewrite device identifiers. + for legacy_device in dr.async_entries_for_config_entry( + dev_reg, legacy_entry.entry_id + ): + new_identifiers = { + (DOMAIN if ident_domain == LEGACY_DOMAIN else ident_domain, ident_value) + for (ident_domain, ident_value) in legacy_device.identifiers + } + dev_reg.async_update_device( + legacy_device.id, new_identifiers=new_identifiers + ) - if migrated_count: - message = ( - f"Migrated {migrated_count} Chargesplit config " - f"entr{'y' if migrated_count == 1 else 'ies'} from the legacy " - "`Chargesplit` domain to `chargesplit`. Entity IDs were preserved, " - "so dashboards, automations, and statistics history continue to work." + # Detach unrecognized rows so the upcoming legacy-entry removal doesn't + # cascade-delete them. The notification below flags them for the user. + for legacy_row in er.async_entries_for_config_entry( + ent_reg, legacy_entry.entry_id + ): + ent_reg.async_update_entity(legacy_row.entity_id, config_entry_id=None) + + # Step 3: create (or adopt, on retry) the chargesplit entry. + new_entry = _find_existing_chargesplit_entry(hass, serial) + if new_entry is None: + new_entry = ConfigEntry( + version=legacy_entry.version, + minor_version=legacy_entry.minor_version, + domain=DOMAIN, + title=legacy_entry.title, + data=dict(legacy_entry.data), + options=dict(legacy_entry.options), + source=SOURCE_IMPORT, + unique_id=legacy_entry.unique_id, + discovery_keys=MappingProxyType({}), + subentries_data=None, ) - if unrecognized: - message += ( - "\n\nThese entities had an unrecognized unique_id format and " - "were left untouched — you may need to delete them manually:\n" - + "\n".join(f"- {eid}" for eid in unrecognized) - ) - persistent_notification.async_create( - hass, - message, - title="Chargesplit migration", - notification_id="chargesplit_legacy_migration", + await hass.config_entries.async_add(new_entry) + + # Step 4: schedule legacy removal after our caller's setup_lock releases. + hass.async_create_task( + hass.config_entries.async_remove(legacy_entry.entry_id), + f"chargesplit-migration-remove-{legacy_entry.entry_id}", + ) + + message = ( + "Migrated one Chargesplit config entry from the legacy `Chargesplit` " + "domain to `chargesplit`. Entity IDs were preserved, so dashboards, " + "automations, and statistics history continue to work." + ) + if unrecognized: + message += ( + "\n\nThese entities had an unrecognized unique_id format and " + "were left untouched — you may need to delete them manually:\n" + + "\n".join(f"- {eid}" for eid in unrecognized) ) + persistent_notification.async_create( + hass, + message, + title="Chargesplit migration", + notification_id=f"chargesplit_legacy_migration_{legacy_entry.entry_id}", + ) def _find_existing_chargesplit_entry( diff --git a/custom_components/chargesplit_legacy_shim/__init__.py b/custom_components/chargesplit_legacy_shim/__init__.py new file mode 100644 index 0000000..63722c3 --- /dev/null +++ b/custom_components/chargesplit_legacy_shim/__init__.py @@ -0,0 +1,38 @@ +"""Legacy `Chargesplit` (capitalized) domain shim. + +v0.0.x used DOMAIN="Chargesplit". v0.1.0 renamed to lowercase. Existing +installs have orphaned config entries under the old capitalized domain; +this shim exists so HA can resolve those entries on disk, and so each +one triggers the per-entry migration in `chargesplit.migration`. + +The folder name `chargesplit_legacy_shim` is decoupled from the domain +("Chargesplit") declared in `manifest.json` — HA looks up integrations +by manifest domain, not folder name. The folder is named this way so +that HACS, which scans `custom_components/*/` alphabetically and treats +the first hit as "the" integration, picks `chargesplit/` (and its brand +assets) rather than this shim. + +Once an install has been migrated, no `Chargesplit` config entries +remain and this shim is never loaded again. It can be deleted from +the release once we're confident nobody is upgrading from <0.1.0. +""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from custom_components.chargesplit.migration import async_migrate_entry + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await async_migrate_entry(hass, entry) + # `async_migrate_entry` has scheduled `async_remove(entry.entry_id)` as + # a follow-up task — we can't await it here because we hold this + # entry's setup_lock. Return True so HA marks the entry LOADED for + # the brief window before that task fires. + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + return True diff --git a/custom_components/Chargesplit/brand/icon.png b/custom_components/chargesplit_legacy_shim/brand/icon.png similarity index 100% rename from custom_components/Chargesplit/brand/icon.png rename to custom_components/chargesplit_legacy_shim/brand/icon.png diff --git a/custom_components/Chargesplit/brand/icon@2x.png b/custom_components/chargesplit_legacy_shim/brand/icon@2x.png similarity index 100% rename from custom_components/Chargesplit/brand/icon@2x.png rename to custom_components/chargesplit_legacy_shim/brand/icon@2x.png diff --git a/custom_components/Chargesplit/manifest.json b/custom_components/chargesplit_legacy_shim/manifest.json similarity index 100% rename from custom_components/Chargesplit/manifest.json rename to custom_components/chargesplit_legacy_shim/manifest.json diff --git a/tests/test_migration.py b/tests/test_migration.py index ef4a353..caafa77 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -27,7 +27,7 @@ from custom_components.chargesplit.api import ChargesplitApi from custom_components.chargesplit.migration import ( LEGACY_DOMAIN, - async_migrate_legacy_domain, + async_migrate_entry, ) FIXTURE = Path(__file__).parent / "fixtures" / "wallbox_response.json" @@ -141,10 +141,10 @@ async def test_migration_preserves_entity_ids_and_rewires_to_new_entry(hass): legacy_entry, device, seeded = _seed_legacy(hass) legacy_entry_id = legacy_entry.entry_id - await async_migrate_legacy_domain(hass) + await async_migrate_entry(hass, legacy_entry) await hass.async_block_till_done() - # The legacy config entry is gone. + # The legacy config entry is gone (scheduled removal task ran). assert hass.config_entries.async_get_entry(legacy_entry_id) is None legacy_entries = hass.config_entries.async_entries(LEGACY_DOMAIN) assert legacy_entries == [] @@ -186,18 +186,18 @@ async def test_migration_preserves_entity_ids_and_rewires_to_new_entry(hass): # User-visible notification was raised. notifications = persistent_notification._async_get_or_create_notifications(hass) - assert "chargesplit_legacy_migration" in notifications + assert f"chargesplit_legacy_migration_{legacy_entry_id}" in notifications -async def test_migration_is_idempotent(hass): - _seed_legacy(hass) - await async_migrate_legacy_domain(hass) - await hass.async_block_till_done() - - # Second pass: nothing left to migrate, must be a no-op. +async def test_migration_is_idempotent_when_nothing_to_migrate(hass): + # No legacy entries seeded. async_migrate_entry shouldn't be called by + # the shim either — but if something does invoke it on a non-Chargesplit + # entry by accident, it would need a serial in data. We just verify the + # whole-system invariant: with no legacy entries, no chargesplit entries + # get created out of thin air. new_entries_before = hass.config_entries.async_entries("chargesplit") - await async_migrate_legacy_domain(hass) - await hass.async_block_till_done() + # Nothing to do — the shim's setup_entry only fires when HA finds an + # orphaned Chargesplit entry. No entry, no call. new_entries_after = hass.config_entries.async_entries("chargesplit") assert [e.entry_id for e in new_entries_before] == [ e.entry_id for e in new_entries_after @@ -223,7 +223,7 @@ async def test_migration_resumes_partial_state(hass): existing_new.add_to_hass(hass) existing_id = existing_new.entry_id - await async_migrate_legacy_domain(hass) + await async_migrate_entry(hass, legacy_entry) await hass.async_block_till_done() new_entries = hass.config_entries.async_entries("chargesplit") @@ -247,7 +247,7 @@ async def test_migration_leaves_unknown_unique_id_in_place_and_notifies(hass): ) weird_entity_id = weird.entity_id - await async_migrate_legacy_domain(hass) + await async_migrate_entry(hass, legacy_entry) await hass.async_block_till_done() # Weird entity is detached but still present. @@ -258,5 +258,6 @@ async def test_migration_leaves_unknown_unique_id_in_place_and_notifies(hass): # Notification mentions it. notifications = persistent_notification._async_get_or_create_notifications(hass) - assert "chargesplit_legacy_migration" in notifications - assert weird_entity_id in notifications["chargesplit_legacy_migration"]["message"] + notification_id = f"chargesplit_legacy_migration_{legacy_entry.entry_id}" + assert notification_id in notifications + assert weird_entity_id in notifications[notification_id]["message"] From d9bc44eea0d2552504c88b480f1b62583037fe8f Mon Sep 17 00:00:00 2001 From: Giovanni Condello Date: Wed, 13 May 2026 22:34:58 +0200 Subject: [PATCH 7/8] =?UTF-8?q?Strip=20migration=20shim=20=E2=80=94=20v0.1?= =?UTF-8?q?.0=20will=20live=20in=20a=20new=20HACS=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a two-repo strategy. HACS's default integration installer only handles one folder under `custom_components/` per release (verified in hacs/integration `repositories/integration.py::get_first_directory_in_directory`), so the dual-folder shim approach we built can't actually ship via HACS: `chargesplit_legacy_shim/` would never reach users' disks, the legacy- domain entries would stay orphaned, and migration wouldn't fire. Plan: - This repo (`ChargesplitHomeAssistant`) gets one more release, v0.0.9. It stays on the existing `Chargesplit/` folder; v0.0.9 adds migration code to the legacy integration's `async_setup_entry` that rewrites storage and creates placeholder `chargesplit`-domain entries, then fires a persistent_notification pointing users to the new repo. - New repo (lowercase, name TBD) ships v0.1.0 as a clean integration with `domain: "chargesplit"`. No migration code, no shim, no legacy baggage. The v0.0.9 step pre-migrates registry rows so v0.1.0's platforms adopt them via `async_get_or_create` matching on `(platform, unique_id)`. This commit removes everything that won't survive the move to the new repo: - `custom_components/chargesplit_legacy_shim/` — the entire shim folder. - `custom_components/chargesplit/migration.py` — migration module. - `tests/test_migration.py`, `tests/test_migration_mapping.py` — the migration tests. Kept: - The chargesplit integration itself (renamed domain, MRO refactor, entity-name improvements, numeric coercion merged from v0.0.8 main). - `tests/test_setup_contract.py` — v0.1.0 fresh-install contract. - `tests/test_coordinator.py` — numeric coercion tests. - pytest scaffolding + CI workflow. 7/7 tests green. Branch is now ready to be pushed to the new repo as its initial state. Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/chargesplit/migration.py | 213 -------------- .../chargesplit_legacy_shim/__init__.py | 38 --- .../chargesplit_legacy_shim/brand/icon.png | Bin 16149 -> 0 bytes .../chargesplit_legacy_shim/brand/icon@2x.png | Bin 45192 -> 0 bytes .../chargesplit_legacy_shim/manifest.json | 13 - tests/test_migration.py | 263 ------------------ tests/test_migration_mapping.py | 76 ----- 7 files changed, 603 deletions(-) delete mode 100644 custom_components/chargesplit/migration.py delete mode 100644 custom_components/chargesplit_legacy_shim/__init__.py delete mode 100644 custom_components/chargesplit_legacy_shim/brand/icon.png delete mode 100644 custom_components/chargesplit_legacy_shim/brand/icon@2x.png delete mode 100644 custom_components/chargesplit_legacy_shim/manifest.json delete mode 100644 tests/test_migration.py delete mode 100644 tests/test_migration_mapping.py diff --git a/custom_components/chargesplit/migration.py b/custom_components/chargesplit/migration.py deleted file mode 100644 index c10c3ae..0000000 --- a/custom_components/chargesplit/migration.py +++ /dev/null @@ -1,213 +0,0 @@ -"""One-shot migration from the legacy capitalized `Chargesplit` domain. - -v0.0.x shipped under DOMAIN="Chargesplit" because the brands proxy at -brands.home-assistant.io was case-insensitive at the time. It later started -requiring all-lowercase domains, so v0.1.0 renamed to `chargesplit`. Existing -installs end up with orphaned entries: the old config entry, all entity -registry rows (platform="Chargesplit", verbose unique_ids), and the device -row (identifiers={("Chargesplit", serial)}) all point at an integration that -no longer exists on disk. - -This module rewrites that state in-place so entity_ids stay stable — -preserving long-term-statistics history (keyed by entity_id), dashboards, -and automations. - -Trigger. The sibling `custom_components/Chargesplit/` shim declares -`dependencies: ["chargesplit"]`. HA loads the shim because legacy entries -reference it, and the dependency makes it load `chargesplit` first. The -shim's `async_setup_entry` then calls `async_migrate_entry` for each -legacy entry; that function is the only entry point in this module. -""" - -from __future__ import annotations - -import logging -from types import MappingProxyType -from typing import Final - -from homeassistant.components import persistent_notification -from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -LEGACY_DOMAIN: Final = "Chargesplit" - -# Description text in the old (v0.0.x) sensor unique_id `Chargesplit-{serial}-{description}-{serial}` -# → suffix used in the v0.1.0 unique_id `{serial}_{suffix}`. -_SENSOR_DESCRIPTION_TO_SUFFIX: Final[dict[str, str]] = { - "Voltage L1": "voltage_l1", - "Voltage L2": "voltage_l2", - "Voltage L3": "voltage_l3", - "Temperature": "temperature", - "Wallbox Status": "status", - "Wallbox Model": "model", - "Wallbox firmware": "firmware", - "Wallbox serial": "serial", - "Charged kWh": "total_charged_kwh", - "Pilot Amps": "pilot_amps", - "Actual Amps": "actual_amps", - "Actual solar power": "solar_power", - "Actual House Consumption": "house_power", - "Car Charging Power": "charging_power", - "Daily House Wh": "daily_house_wh", - "Daily Solar Wh": "daily_solar_wh", - "Schedule": "schedule", -} - -# v0.0.x select unique_ids are `{serial}-{key}` for these keys. -_SELECT_KEYS: Final = ("operation_mode", "lock_mode", "pause_mode") - - -def migrate_unique_id(old: str, serial: str) -> str | None: - """Translate a v0.0.x unique_id to the v0.1.0 shape, or None if unrecognized. - - Anchored on the known serial so that serials containing dashes parse safely. - """ - if old.startswith(f"{LEGACY_DOMAIN}-{serial}-") and old.endswith(f"-{serial}"): - # Sensor: Chargesplit-{serial}-{description}-{serial} - description = old[len(f"{LEGACY_DOMAIN}-{serial}-") : -len(f"-{serial}")] - suffix = _SENSOR_DESCRIPTION_TO_SUFFIX.get(description) - if suffix is None: - return None - return f"{serial}_{suffix}" - - for key in _SELECT_KEYS: - if old == f"{serial}-{key}": - return f"{serial}_{key}" - - return None - - -async def async_migrate_entry(hass: HomeAssistant, legacy_entry: ConfigEntry) -> None: - """Migrate one orphaned `Chargesplit` config entry to `chargesplit`. - - Sequencing (all public API): - - 1. Rewrite the legacy entity rows in place: new `platform=chargesplit` - and new `unique_id`, but `new_config_entry_id=legacy_entry.entry_id` - (the gating value, not a real change — `async_update_entity_platform` - refuses UNDEFINED when a row is already linked). - 2. Rewrite the device's identifier tuple, leaving config-entry - membership alone. - 3. `async_add` the new entry. Its platform setup calls - `async_get_or_create(... platform, unique_id)` and - `async_get_or_create(... identifiers=...)`, which match the - pre-migrated rows and adopt them — `_async_update_entity` / - `_async_update_device` set `config_entry_id` to the new entry as - a side effect. - 4. Schedule the legacy entry's removal as a follow-up task. We can't - `await async_remove(legacy_entry.entry_id)` synchronously: this - function runs inside the shim's `async_setup_entry`, which holds - the legacy entry's setup_lock; `async_remove` would try to acquire - the same lock and deadlock. `hass.async_create_task` defers it - past the current setup, where the lock is no longer held. - - Idempotent: if a previous attempt crashed after step 3 but before - step 4, the next pass re-runs steps 1-2 as no-ops on the already- - migrated rows, finds the existing chargesplit entry in step 3 and - reuses it, and re-schedules removal in step 4. - """ - serial = legacy_entry.data.get("serial") - if not serial: - _LOGGER.warning( - "Legacy Chargesplit entry %s has no serial in data; skipping", - legacy_entry.entry_id, - ) - return - - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - unrecognized: list[str] = [] - - # Step 1: rewrite entity rows in place. - for legacy_row in er.async_entries_for_config_entry( - ent_reg, legacy_entry.entry_id - ): - new_unique_id = migrate_unique_id(legacy_row.unique_id, serial) - if new_unique_id is None: - _LOGGER.warning( - "Could not migrate entity %s (unique_id=%s); leaving under legacy domain", - legacy_row.entity_id, - legacy_row.unique_id, - ) - unrecognized.append(legacy_row.entity_id) - continue - ent_reg.async_update_entity_platform( - legacy_row.entity_id, - DOMAIN, - new_config_entry_id=legacy_entry.entry_id, - new_unique_id=new_unique_id, - ) - - # Step 2: rewrite device identifiers. - for legacy_device in dr.async_entries_for_config_entry( - dev_reg, legacy_entry.entry_id - ): - new_identifiers = { - (DOMAIN if ident_domain == LEGACY_DOMAIN else ident_domain, ident_value) - for (ident_domain, ident_value) in legacy_device.identifiers - } - dev_reg.async_update_device( - legacy_device.id, new_identifiers=new_identifiers - ) - - # Detach unrecognized rows so the upcoming legacy-entry removal doesn't - # cascade-delete them. The notification below flags them for the user. - for legacy_row in er.async_entries_for_config_entry( - ent_reg, legacy_entry.entry_id - ): - ent_reg.async_update_entity(legacy_row.entity_id, config_entry_id=None) - - # Step 3: create (or adopt, on retry) the chargesplit entry. - new_entry = _find_existing_chargesplit_entry(hass, serial) - if new_entry is None: - new_entry = ConfigEntry( - version=legacy_entry.version, - minor_version=legacy_entry.minor_version, - domain=DOMAIN, - title=legacy_entry.title, - data=dict(legacy_entry.data), - options=dict(legacy_entry.options), - source=SOURCE_IMPORT, - unique_id=legacy_entry.unique_id, - discovery_keys=MappingProxyType({}), - subentries_data=None, - ) - await hass.config_entries.async_add(new_entry) - - # Step 4: schedule legacy removal after our caller's setup_lock releases. - hass.async_create_task( - hass.config_entries.async_remove(legacy_entry.entry_id), - f"chargesplit-migration-remove-{legacy_entry.entry_id}", - ) - - message = ( - "Migrated one Chargesplit config entry from the legacy `Chargesplit` " - "domain to `chargesplit`. Entity IDs were preserved, so dashboards, " - "automations, and statistics history continue to work." - ) - if unrecognized: - message += ( - "\n\nThese entities had an unrecognized unique_id format and " - "were left untouched — you may need to delete them manually:\n" - + "\n".join(f"- {eid}" for eid in unrecognized) - ) - persistent_notification.async_create( - hass, - message, - title="Chargesplit migration", - notification_id=f"chargesplit_legacy_migration_{legacy_entry.entry_id}", - ) - - -def _find_existing_chargesplit_entry( - hass: HomeAssistant, serial: str -) -> ConfigEntry | None: - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data.get("serial") == serial: - return entry - return None diff --git a/custom_components/chargesplit_legacy_shim/__init__.py b/custom_components/chargesplit_legacy_shim/__init__.py deleted file mode 100644 index 63722c3..0000000 --- a/custom_components/chargesplit_legacy_shim/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Legacy `Chargesplit` (capitalized) domain shim. - -v0.0.x used DOMAIN="Chargesplit". v0.1.0 renamed to lowercase. Existing -installs have orphaned config entries under the old capitalized domain; -this shim exists so HA can resolve those entries on disk, and so each -one triggers the per-entry migration in `chargesplit.migration`. - -The folder name `chargesplit_legacy_shim` is decoupled from the domain -("Chargesplit") declared in `manifest.json` — HA looks up integrations -by manifest domain, not folder name. The folder is named this way so -that HACS, which scans `custom_components/*/` alphabetically and treats -the first hit as "the" integration, picks `chargesplit/` (and its brand -assets) rather than this shim. - -Once an install has been migrated, no `Chargesplit` config entries -remain and this shim is never loaded again. It can be deleted from -the release once we're confident nobody is upgrading from <0.1.0. -""" - -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from custom_components.chargesplit.migration import async_migrate_entry - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - await async_migrate_entry(hass, entry) - # `async_migrate_entry` has scheduled `async_remove(entry.entry_id)` as - # a follow-up task — we can't await it here because we hold this - # entry's setup_lock. Return True so HA marks the entry LOADED for - # the brief window before that task fires. - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - return True diff --git a/custom_components/chargesplit_legacy_shim/brand/icon.png b/custom_components/chargesplit_legacy_shim/brand/icon.png deleted file mode 100644 index 812d109d65be5ffd34806d5c47ab6fd3cb9bdd4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16149 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 MQc0p#+&K9E0V#yr>;M1& diff --git a/custom_components/chargesplit_legacy_shim/brand/icon@2x.png b/custom_components/chargesplit_legacy_shim/brand/icon@2x.png deleted file mode 100644 index cb67c940ff5854e1eba2ff477c1cbd664f92edd3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45192 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

j5&?SF(r zd-Q93tAm!;CEmAZTPNJZod72o5cRfMX`WO@wr7>EjJLYZDkB&1510!GI0_H_4G0q2 zECOtdZ^4}+_+Rbvlnw(2`FiVm3% zH1jU*0(6oAT=D9XTG7lA{urNZ5LWdX9ZX#>tN`RYs`s%UT%ii9w~twJF!g_0jQ7;- zZy?m*;cvnNwqf~;GkAn=Z?~WM)HxUpc`al|J&m{e71dn;+INq50vnHSnwwr`B`Df%c@a{VYeiu|0Au&<|hNeJ{JGyQ=qn5qzKfV_QB1pRK-x512+Xctc;}Ft@154Myz6U0=7; zcGM>=B9;!<6*-J+;-sUHr5gUwHv*dc^gJ^d$}^XVBGtkN9IkBWQIawO9@4uf28%=s zwqYwoF6PsW_@B@0!U|NM5zMoG7P0pCr6>azq`e@r+gw^eO zrc!XryQP`YgBwWBWgsX)Xh%=+`mrS5DS}bxKzOfTes21cWo4Azo%!QkZoGOdD!HRL zO%M6{@5yBm;fJYXd`&MyIQ$BlnH58S5*2}A6i*8 z@>Ad7+oB%4^Ol%`-wu3-!FRvH?(mhFT2zE;u4B>WWuo#(=nn`YizLa_#THOubjOjhQ z>q}%aA?;z0ccOfAm8}X}>Bb$BpFN%~2yhiu>R}y`i|0lbCyy5EahL(BF8Mb8;k6o6 z*r4XE&nG5YMtP0J%td>q08l5rP1}b@J&BMkIj3f zn3jK2*3VTY;p10`Z_fe1;ch5miRtcqOj2%dlva3eYsfooiS{zdW~pw|tMPBVkg2)A zF74Tmt->!wr`4%yN;6^K2meZ<5|Twmxksozu-nK`D2aDSIr8H7URSe4dBbyLUCpes7nzfJ9< zm_&pnkZhcs4I83;@bT8T@Rg+(0LoH4B5b{VCb76|Ks0td4RdH*=xRmbsffAl!zJ<&uPa}2JXP(&VL25n) zRyb?!a!=1Ou6p{wY{|v4!)yKG?k%OvxY+dH^DodZiv5DAAGD|u=T=q=9^4I8tEEja zHVnXy5cp|m0VpdRYhsZ{kiPz0Lm-v*@wFg2Ivr}C%d5|r0D6eOaX&=8FrK@M>C*kQ z=U?*xRQP^uAAw0Ua4=Bi2@ko6FGX|w5DNxYV3>x7i)~$B8Sp|s?*DMZe=5w=Y`Ooz z)yJb^qS^KaZCakafh{i=`Z?v+n)%18#e{@p+4TnKaED+uEIu-RBfI&l6O4t{3}fy3 ziDq_u%xBa|tto;4F|GF$UoBQUYjj8pgeKLjr)w*hpq1^yJSt^ef$EDV?U|DU^I_&x z8oFF{zGCGuy7rmgceuxkM(U&{6UTJtuEd|$wsE|~gP(y6UTv$bHC@8Cmx?9wk17qB z+0PB!m^h$Cie%+|)FM1Tu)|c&s&L_MU~T|7g1HT+75HFWTF!ECjX(U!@2%phic7UNP!=}@!y<$6XDS6l0+<8=9kr4nvpdjF>+w-yhv8kW%+kk?-n8q?8qa7 zZsNKU6^CeV&phIO42;4K%XK>09$}>PDAo0I`6xe1KJPp4&|!4)L9q;z*PKFXd0q4t zmUdz!M{%4~{v;qYlUX-szcl*>j}U`{W^bfO9IE!KzfLqj`nTE``&wSenuKx%*9&Dz zZWd0#tTLn`bZs#Ax{f=^%2s!*UD|hJE$%*(+_&X??-ZJGeZ5|^ z$Z;fy0svRAuVV5~y(Rh#;$TZ`aHD@exYC*lb`N~v$+o3EbA|~tLmwh9jy}$7*m`yG z{%A=-ilxc0@D^5EMQG40s?m%q&gG?tj~iecx^JiB$f4yrwaBWF^rgkUPY#aWTT|9? z!O#*xEBC}JODX0T0mAWcF>*1Ot7WN30NjgFLz%yZEgWWS-@MwgLCs|;IJyUn>i;^f zi39!|pC%wRv1>R~s7^m0Ok{OwZL4Z_$7IzgR)6f*(j(9!X z55w>J+uIvyWqz4nx-IjcK~prH96WeIZXgigf4AGXhyH2$99iV5clqFDwf3rpZ+4Gq zLNJ*pbtv?UZsniT@ML-Z;smSPoPq>2lVShZB8~1@fGr|v#Kyw?Q)8cGZW zCCvvr8n$}spDe*`h?Mb@y+705IdOaKtLsbdd0DWhoOFGTBOh<(S}^#1k1V_buV43U z2zA@QIkQzFb421HWnT z>N;B2LkIwMiCvrb^+#!Yu8oeK9`WOv7cL%7`u1K8Q#O1G&_V$=>Wx*K_0Ehhs%HYc zrEUYk^S^Wi`2b}ZX(MXWfkfFHOd;}+j}u{ETtB{#>Z>kb$f=!a!E8dBs8DsppLm8pd-ucRn;qm{2TuzBx;_9Cn` zh2Hl=Fe2jqY=|1D|I%lcF*d=-lHmvL^Ul@L~ zpFD%1M=BjQRb+{SCJ}O6hMgp%v$NVRuG8A2p19s~^97rZaW&lYi>u90_D`0pJ11iv z_w|cHQ@&nX+Eu(>@AEc^ETwfRbK_4VEJ$UiyGWO0jN8xs$w~UL)qX|uF-{h~>}&s> zPPKGZ)=ysm_E!!dgyX*m{j&!-f2#BBS0dQoVP(dvx9bS4Z0-q%=@A~gV~>>nCd*4w zL|NE?);Q;m7ojyxv1Wz=3K?%pKrsUxYic6eDGaT-)FdfaNi&(O}loDfbdBBx4z z8&1pRU9?y9Z_d1a?=GY2sK}ma%8koVEAg&tty*|gXu)fL!4Q6S$G=P%myx4$xg{!J z?uy9$*7t|&fc_K>LEC071^_F*JabdfB>{^j)8m7si0-o8h=0bDBMP4xY&f#gWnFae z78LK`UGRS-X)@|X>HkXADDz#C$-5JOZ!g@OMA-InS1Z0)vZ&t~`=yzq(lD5qEqY|M z!R}pjn@8HMn%{(1MY+@%%5EA>BwU!j8$K0*o$I_b?_iGkb}b`9UFP?tEYqAUvuv0` ze0vhD70%&`e$Lzu)}pd?_2|&dZL7uac+2oOhD#CbSGTbFq(Axb5qS6M9L=Pf3fRE~ z9kX?>Re9r-Bb`H>TEc03OOFRg{pR#x!#bO=pVsHQ8>cSYgVjf~T}{iPDI6ERH*FzT z8_j!(+KBEVY-!l-@oQ87cvdhQa3YyRmozK9zRn&}%t5_i=@JJw8cDDzmA3v}vY*2> zIi}5mg@eXd`!}{d_q){(%5lu`yX5h|i(JgFh(A+xzfiQ!O=pqh2P47!HeMAl_-q;Es-l26i7fW;MRwmbe}4k z5R(qcSsmNKT3;)lNyv)c;7mFCM)dQ`>gZbPPkP@OvZW*@6Hn^i|28HkBP+TLWC;=g z--7*-&w~*(S~ec7Age* zfZJ6g@3|O7L3z`gFIWH4M-Tpd;upOvSr9q Date: Wed, 13 May 2026 22:42:19 +0200 Subject: [PATCH 8/8] Point manifest + README at the new repo URL This branch is the content that will move to nanomad/hass-chargesplit. Update the manifest's documentation/issue_tracker fields and the README's HACS-install badge so they target the new repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- custom_components/chargesplit/manifest.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 62db57d..d6cdbbb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ follow the [instructions for adding a custom repository](https://hacs.xyz/docs/faq/custom_repositories) and then the integration will be available to install like any other. -[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=nanomad&repository=ChargesplitHomeAssistant&category=integration) +[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=nanomad&repository=hass-chargesplit&category=integration) ## Configuration diff --git a/custom_components/chargesplit/manifest.json b/custom_components/chargesplit/manifest.json index f98a8ad..094aebe 100644 --- a/custom_components/chargesplit/manifest.json +++ b/custom_components/chargesplit/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@nanomad"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/nanomad/ChargesplitHomeAssistant", + "documentation": "https://github.com/nanomad/hass-chargesplit", "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/nanomad/ChargesplitHomeAssistant/issues", + "issue_tracker": "https://github.com/nanomad/hass-chargesplit/issues", "requirements": ["requests>=2.28.0"], "version": "0.1.0" }