From 7b1f2abee83a8a9b779ecc7a195d3a431b494336 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 2 Nov 2024 16:26:57 +0000 Subject: [PATCH 01/11] WIP --- custom_components/battery_notes/device.py | 31 ++++++++--- custom_components/battery_notes/repairs.py | 51 +++++++++++++++++++ .../battery_notes/translations/en.json | 13 +++++ 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 custom_components/battery_notes/repairs.py diff --git a/custom_components/battery_notes/device.py b/custom_components/battery_notes/device.py index 70540bb6..81865ecd 100644 --- a/custom_components/battery_notes/device.py +++ b/custom_components/battery_notes/device.py @@ -13,12 +13,9 @@ PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, -) -from homeassistant.helpers import ( - entity_registry as er, -) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_registry import RegistryEntry from .const import ( @@ -27,6 +24,7 @@ CONF_BATTERY_QUANTITY, CONF_BATTERY_TYPE, CONF_DEFAULT_BATTERY_LOW_THRESHOLD, + CONF_DEVICE_NAME, CONF_SOURCE_ENTITY_ID, DATA, DATA_STORE, @@ -88,12 +86,33 @@ async def async_setup(self) -> bool: device_id = config.data.get(CONF_DEVICE_ID, None) source_entity_id = config.data.get(CONF_SOURCE_ENTITY_ID, None) + device_name = config.data.get(CONF_DEVICE_NAME) device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) if source_entity_id: entity = entity_registry.async_get(source_entity_id) + + if not entity: + ir.async_create_issue( + self.hass, + DOMAIN, + f"missing_device_{self.config.entry_id}", + data={ + "entry_id": self.config.entry_id, + "device_id": device_id, + "source_entity_id": source_entity_id, + "device_name": device_name, + }, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="missing_device", + translation_placeholders={ + "name": device_name, + }, + ) + device_class = entity.device_class or entity.original_device_class if ( device_class == SensorDeviceClass.BATTERY diff --git a/custom_components/battery_notes/repairs.py b/custom_components/battery_notes/repairs.py new file mode 100644 index 00000000..26f55c08 --- /dev/null +++ b/custom_components/battery_notes/repairs.py @@ -0,0 +1,51 @@ +"""Repairs for battery_notes.""" + +from __future__ import annotations + +import voluptuous as vol +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +class MissingDeviceRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entry_id = data["entry_id"] + self.device_id = data["device_id"] + self.source_entity_id = data["source_entity_id"] + self.device_name = data["name"] + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await (self.async_step_confirm()) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + + device_registry = dr.async_get(self.hass) + device_registry.async_remove_device(self.device_id) + + return self.async_create_entry(title="", data={}) + + return self.async_show_form(step_id="confirm", data_schema=vol.Schema({})) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("missing_device_"): + assert data + return MissingDeviceRepairFlow(data) diff --git a/custom_components/battery_notes/translations/en.json b/custom_components/battery_notes/translations/en.json index b2476d69..efc3b48f 100644 --- a/custom_components/battery_notes/translations/en.json +++ b/custom_components/battery_notes/translations/en.json @@ -182,5 +182,18 @@ "description": "Raise events for devices that have a low battery.", "name": "Check battery low" } + }, + "issues": { + "missing_device": { + "title": "Orphaned Battery Note", + "description": "The associated device or entity no longer exists for the Battery Note entry {name}, the Battery Note should be deleted.", + "fix_flow": { + "step": { + "confirm_delete_entity": { + "description": "Delete this Battery Note" + } + } + } + } } } \ No newline at end of file From 620b7b0b8a3481e25b364999e123e3cc34340bab Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 3 Nov 2024 10:56:11 +0000 Subject: [PATCH 02/11] WIP --- custom_components/battery_notes/repairs.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/custom_components/battery_notes/repairs.py b/custom_components/battery_notes/repairs.py index 26f55c08..32220b7d 100644 --- a/custom_components/battery_notes/repairs.py +++ b/custom_components/battery_notes/repairs.py @@ -4,9 +4,10 @@ import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import issue_registry as ir class MissingDeviceRepairFlow(RepairsFlow): @@ -37,7 +38,16 @@ async def async_step_confirm( return self.async_create_entry(title="", data={}) - return self.async_show_form(step_id="confirm", data_schema=vol.Schema({})) + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm_delete_entity", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders + ) async def async_create_fix_flow( From 258d6d98fe45f314d724423f512d4d7d0c336f6a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 3 Nov 2024 15:39:24 +0000 Subject: [PATCH 03/11] WIP --- custom_components/battery_notes/device.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/custom_components/battery_notes/device.py b/custom_components/battery_notes/device.py index 81865ecd..acc25752 100644 --- a/custom_components/battery_notes/device.py +++ b/custom_components/battery_notes/device.py @@ -113,6 +113,12 @@ async def async_setup(self) -> bool: }, ) + _LOGGER.debug( + "%s is orphaned, unable to find entity %s", + self.config.entry_id, + source_entity_id, + ) + device_class = entity.device_class or entity.original_device_class if ( device_class == SensorDeviceClass.BATTERY @@ -169,6 +175,30 @@ async def async_setup(self) -> bool: else: self.device_name = self.config.title + ir.async_create_issue( + self.hass, + DOMAIN, + f"missing_device_{self.config.entry_id}", + data={ + "entry_id": self.config.entry_id, + "device_id": device_id, + "source_entity_id": source_entity_id, + "device_name": device_name, + }, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="missing_device", + translation_placeholders={ + "name": device_name, + }, + ) + + _LOGGER.debug( + "%s is orphaned, unable to find device %s", + self.config.entry_id, + device_id, + ) + self.store = self.hass.data[DOMAIN][DATA_STORE] self.coordinator = BatteryNotesCoordinator( self.hass, self.store, self.wrapped_battery From 20c86d54e9a3263acbaa1a7742c93eaa9f26c90a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 3 Nov 2024 16:29:18 +0000 Subject: [PATCH 04/11] WIP --- custom_components/battery_notes/device.py | 11 ++++------- custom_components/battery_notes/repairs.py | 9 +++------ custom_components/battery_notes/translations/en.json | 6 +++--- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/custom_components/battery_notes/device.py b/custom_components/battery_notes/device.py index acc25752..4c85bda0 100644 --- a/custom_components/battery_notes/device.py +++ b/custom_components/battery_notes/device.py @@ -86,7 +86,6 @@ async def async_setup(self) -> bool: device_id = config.data.get(CONF_DEVICE_ID, None) source_entity_id = config.data.get(CONF_SOURCE_ENTITY_ID, None) - device_name = config.data.get(CONF_DEVICE_NAME) device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) @@ -103,17 +102,16 @@ async def async_setup(self) -> bool: "entry_id": self.config.entry_id, "device_id": device_id, "source_entity_id": source_entity_id, - "device_name": device_name, }, is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="missing_device", translation_placeholders={ - "name": device_name, + "name": config.title, }, ) - _LOGGER.debug( + _LOGGER.warning( "%s is orphaned, unable to find entity %s", self.config.entry_id, source_entity_id, @@ -183,17 +181,16 @@ async def async_setup(self) -> bool: "entry_id": self.config.entry_id, "device_id": device_id, "source_entity_id": source_entity_id, - "device_name": device_name, }, is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="missing_device", translation_placeholders={ - "name": device_name, + "name": config.title, }, ) - _LOGGER.debug( + _LOGGER.warning( "%s is orphaned, unable to find device %s", self.config.entry_id, device_id, diff --git a/custom_components/battery_notes/repairs.py b/custom_components/battery_notes/repairs.py index 32220b7d..7eceb1c1 100644 --- a/custom_components/battery_notes/repairs.py +++ b/custom_components/battery_notes/repairs.py @@ -6,7 +6,6 @@ from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from homeassistant.helpers import issue_registry as ir @@ -18,7 +17,6 @@ def __init__(self, data: dict[str, str]) -> None: self.entry_id = data["entry_id"] self.device_id = data["device_id"] self.source_entity_id = data["source_entity_id"] - self.device_name = data["name"] async def async_step_init( self, user_input: dict[str, str] | None = None @@ -32,9 +30,8 @@ async def async_step_confirm( ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - - device_registry = dr.async_get(self.hass) - device_registry.async_remove_device(self.device_id) + print(self.entry_id) + await self.hass.config_entries.async_remove(self.entry_id) return self.async_create_entry(title="", data={}) @@ -44,7 +41,7 @@ async def async_step_confirm( description_placeholders = issue.translation_placeholders return self.async_show_form( - step_id="confirm_delete_entity", + step_id="confirm", data_schema=vol.Schema({}), description_placeholders=description_placeholders ) diff --git a/custom_components/battery_notes/translations/en.json b/custom_components/battery_notes/translations/en.json index efc3b48f..4e820b73 100644 --- a/custom_components/battery_notes/translations/en.json +++ b/custom_components/battery_notes/translations/en.json @@ -186,11 +186,11 @@ "issues": { "missing_device": { "title": "Orphaned Battery Note", - "description": "The associated device or entity no longer exists for the Battery Note entry {name}, the Battery Note should be deleted.", "fix_flow": { "step": { - "confirm_delete_entity": { - "description": "Delete this Battery Note" + "confirm": { + "title": "Orphaned Battery Note", + "description": "The associated device or entity no longer exists for the Battery Note entry {name}, the Battery Note should be deleted.\nSelect **Submit** to delete this Battery Note." } } } From 6b339c3c0866779cad86667cc5a6bc20df5fd10b Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 3 Nov 2024 16:29:44 +0000 Subject: [PATCH 05/11] Remove print statement --- custom_components/battery_notes/repairs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/battery_notes/repairs.py b/custom_components/battery_notes/repairs.py index 7eceb1c1..4cdfb1bd 100644 --- a/custom_components/battery_notes/repairs.py +++ b/custom_components/battery_notes/repairs.py @@ -30,7 +30,6 @@ async def async_step_confirm( ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - print(self.entry_id) await self.hass.config_entries.async_remove(self.entry_id) return self.async_create_entry(title="", data={}) From 36b82c428c9c7755f0baec3f3e766cbf1da42e64 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 4 Nov 2024 09:57:36 +0000 Subject: [PATCH 06/11] Change logging --- custom_components/battery_notes/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/battery_notes/device.py b/custom_components/battery_notes/device.py index 4c85bda0..9414c666 100644 --- a/custom_components/battery_notes/device.py +++ b/custom_components/battery_notes/device.py @@ -111,7 +111,7 @@ async def async_setup(self) -> bool: }, ) - _LOGGER.warning( + _LOGGER.debug( "%s is orphaned, unable to find entity %s", self.config.entry_id, source_entity_id, @@ -190,7 +190,7 @@ async def async_setup(self) -> bool: }, ) - _LOGGER.warning( + _LOGGER.debug( "%s is orphaned, unable to find device %s", self.config.entry_id, device_id, From 0bb05f3fb5fe130f14d4191dea6db8874014361b Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 4 Nov 2024 10:53:21 +0000 Subject: [PATCH 07/11] Lint --- custom_components/battery_notes/device.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/battery_notes/device.py b/custom_components/battery_notes/device.py index 9414c666..63aa3e8d 100644 --- a/custom_components/battery_notes/device.py +++ b/custom_components/battery_notes/device.py @@ -24,7 +24,6 @@ CONF_BATTERY_QUANTITY, CONF_BATTERY_TYPE, CONF_DEFAULT_BATTERY_LOW_THRESHOLD, - CONF_DEVICE_NAME, CONF_SOURCE_ENTITY_ID, DATA, DATA_STORE, From f9c396697ad174a0006beff16142e9cbf45d43d5 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 4 Nov 2024 11:25:28 +0000 Subject: [PATCH 08/11] Error handling --- .../battery_notes/config_flow.py | 39 ++++++++++++------- custom_components/battery_notes/device.py | 6 ++- .../battery_notes/translations/en.json | 1 + 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/custom_components/battery_notes/config_flow.py b/custom_components/battery_notes/config_flow.py index eaa4b402..61b6fced 100644 --- a/custom_components/battery_notes/config_flow.py +++ b/custom_components/battery_notes/config_flow.py @@ -448,20 +448,23 @@ async def async_step_init( device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get(self.source_device_id) - _LOGGER.debug( - "Looking up device %s %s %s %s", - device_entry.manufacturer, - device_entry.model, - get_device_model_id(device_entry) or "", - device_entry.hw_version, - ) + if not device_entry: + errors["base"] = "orphaned_battery_note" + else: + _LOGGER.debug( + "Looking up device %s %s %s %s", + device_entry.manufacturer, + device_entry.model, + get_device_model_id(device_entry) or "", + device_entry.hw_version, + ) - self.model_info = ModelInfo( - device_entry.manufacturer, - device_entry.model, - get_device_model_id(device_entry), - device_entry.hw_version, - ) + self.model_info = ModelInfo( + device_entry.manufacturer, + device_entry.model, + get_device_model_id(device_entry), + device_entry.hw_version, + ) schema = self.build_options_schema() if user_input is not None: @@ -492,6 +495,8 @@ async def save_options( schema: vol.Schema, ) -> dict: """Save options, and return errors when validation fails.""" + errors = {} + device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get( self.config_entry.data.get(CONF_DEVICE_ID) @@ -503,6 +508,14 @@ async def save_options( entity_registry = er.async_get(self.hass) entity_entry = entity_registry.async_get(source_entity_id) + if not device_entry: + errors["base"] = "orphaned_battery_note" + return errors + + if source_entity_id and not entity_entry: + errors["base"] = "orphaned_battery_note" + return errors + if CONF_NAME in user_input: title = user_input.get(CONF_NAME) elif source_entity_id: diff --git a/custom_components/battery_notes/device.py b/custom_components/battery_notes/device.py index 63aa3e8d..2e02c007 100644 --- a/custom_components/battery_notes/device.py +++ b/custom_components/battery_notes/device.py @@ -110,11 +110,12 @@ async def async_setup(self) -> bool: }, ) - _LOGGER.debug( + _LOGGER.warning( "%s is orphaned, unable to find entity %s", self.config.entry_id, source_entity_id, ) + return False device_class = entity.device_class or entity.original_device_class if ( @@ -189,11 +190,12 @@ async def async_setup(self) -> bool: }, ) - _LOGGER.debug( + _LOGGER.warning( "%s is orphaned, unable to find device %s", self.config.entry_id, device_id, ) + return False self.store = self.hass.data[DOMAIN][DATA_STORE] self.coordinator = BatteryNotesCoordinator( diff --git a/custom_components/battery_notes/translations/en.json b/custom_components/battery_notes/translations/en.json index 4e820b73..e4463965 100644 --- a/custom_components/battery_notes/translations/en.json +++ b/custom_components/battery_notes/translations/en.json @@ -75,6 +75,7 @@ } }, "error": { + "orphaned_battery_note": "The associated device or entity no longer exists for this Battery Note.", "unknown": "Unknown error occurred." } }, From 467a91fbfe6eebb4ac7c4224789562247ead5220 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 4 Nov 2024 11:29:41 +0000 Subject: [PATCH 09/11] Error handling --- custom_components/battery_notes/config_flow.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/custom_components/battery_notes/config_flow.py b/custom_components/battery_notes/config_flow.py index 61b6fced..74d9ace5 100644 --- a/custom_components/battery_notes/config_flow.py +++ b/custom_components/battery_notes/config_flow.py @@ -501,20 +501,18 @@ async def save_options( device_entry = device_registry.async_get( self.config_entry.data.get(CONF_DEVICE_ID) ) + if not device_entry: + errors["base"] = "orphaned_battery_note" + return errors source_entity_id = self.config_entry.data.get(CONF_SOURCE_ENTITY_ID, None) if source_entity_id: entity_registry = er.async_get(self.hass) entity_entry = entity_registry.async_get(source_entity_id) - - if not device_entry: - errors["base"] = "orphaned_battery_note" - return errors - - if source_entity_id and not entity_entry: - errors["base"] = "orphaned_battery_note" - return errors + if not entity_entry: + errors["base"] = "orphaned_battery_note" + return errors if CONF_NAME in user_input: title = user_input.get(CONF_NAME) From e81fe71003701cf6fdd38c0ccbd4869780b62e9a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 4 Nov 2024 11:52:24 +0000 Subject: [PATCH 10/11] Error handling for entities --- custom_components/battery_notes/config_flow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/battery_notes/config_flow.py b/custom_components/battery_notes/config_flow.py index 74d9ace5..3816555e 100644 --- a/custom_components/battery_notes/config_flow.py +++ b/custom_components/battery_notes/config_flow.py @@ -501,9 +501,6 @@ async def save_options( device_entry = device_registry.async_get( self.config_entry.data.get(CONF_DEVICE_ID) ) - if not device_entry: - errors["base"] = "orphaned_battery_note" - return errors source_entity_id = self.config_entry.data.get(CONF_SOURCE_ENTITY_ID, None) @@ -513,6 +510,10 @@ async def save_options( if not entity_entry: errors["base"] = "orphaned_battery_note" return errors + else: + if not device_entry: + errors["base"] = "orphaned_battery_note" + return errors if CONF_NAME in user_input: title = user_input.get(CONF_NAME) From 44be23149117822dc00d03ce16f0a05ed3a1b321 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 5 Nov 2024 10:15:25 +0000 Subject: [PATCH 11/11] Remove issues if device deleted --- custom_components/battery_notes/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/battery_notes/__init__.py b/custom_components/battery_notes/__init__.py index 4aaa385c..6925d67d 100644 --- a/custom_components/battery_notes/__init__.py +++ b/custom_components/battery_notes/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -163,6 +164,9 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Device removed, tidy up store.""" + # Remove any issues raised + ir.async_delete_issue(hass, DOMAIN, f"missing_device_{config_entry.entry_id}") + if "device_id" not in config_entry.data: return