Skip to content

Add URL validation, graceful missing-device handling with real-time listeners, and editable merge sources in options flow#73

Merged
EuleMitKeule merged 3 commits into
dev-2.xfrom
copilot/dev-2x-validate-configuration-url
Mar 30, 2026
Merged

Add URL validation, graceful missing-device handling with real-time listeners, and editable merge sources in options flow#73
EuleMitKeule merged 3 commits into
dev-2.xfrom
copilot/dev-2x-validate-configuration-url

Conversation

Copilot AI commented Mar 30, 2026

Copy link
Copy Markdown

Three improvements to the device_tools integration: config-flow URL validation, graceful degradation when referenced devices/entities are deleted (both at startup and in real time), and the ability to edit merge source devices after creation.

URL validation for configuration_url

async_step_modify_device and options-flow async_step_init now validate configuration_url using urllib.parse.urlparse before saving. Invalid values re-show the form with {"base": "invalid_configuration_url"} instead of propagating an HA error. Translation key added to both en.json and de.json.

def _is_valid_url(value: str) -> bool:
    try:
        parsed = urllib.parse.urlparse(value)
        return bool(parsed.scheme and parsed.netloc)
    except ValueError:
        return False

Graceful handling of deleted registry entries (engine.py)

At startup (async_on_entry_loaded)

Stale references are detected before setting up handlers:

  • ENTITY / DEVICE (non-creation) mods — if the target no longer exists, disables the config entry via async_set_disabled_by and skips handler setup.
  • MERGE mods — removes missing source devices from CONF_MODIFICATION_ORIGINAL_DATA and continues with the remaining ones.
  • DEVICE mods — strips missing entity IDs from CONF_ASSIGNED_ENTITIES.

In real time (registry deletion listeners)

Two engine-level bus listeners are registered in async_start and torn down in async_stop:

  • _async_on_entity_registry_updated — on action == "remove": disables any ENTITY modification whose target was the removed entity; strips the entity from CONF_ASSIGNED_ENTITIES of any DEVICE modification that referenced it.
  • _async_on_device_registry_updated — on action == "remove": disables any non-creation DEVICE modification whose target was the removed device; removes the device from CONF_MODIFICATION_ORIGINAL_DATA of any MERGE modification that listed it as a source.

All warnings are gated by a _warned_missing: set[str] (keyed entry_id + "_" + missing_id) so each event logs at most once per session.

Editable merge-source devices in options flow

  • Removed read_only=True from the CONF_MERGE_DEVICE_IDS DeviceSelector.
  • Options-flow async_step_init MERGE branch now recomputes CONF_MODIFICATION_ORIGINAL_DATA: retained devices keep their existing snapshot; newly-added devices have their entity data gathered fresh from the entity registry. The config entry data is updated via async_update_entry.
  • Stale translation label "Devices to merge (cannot be changed currently)" updated in both locale files.
Original prompt

Overview

Three improvements need to be implemented in the EuleMitKeule/device-tools Home Assistant custom integration. The target branch is dev-2.x. After implementing all changes, ensure the CI tools pass: ruff, mypy (strict, against custom_components/device_tools), and pytest (under tests/). The CI commands are:

uv run ruff check .
uv run mypy --strict custom_components/device_tools
uv run pytest --cov=. --cov-report=term tests

Task 1: Validate configuration_url in the config flow

Problem: When a user enters a value for configuration_url that is not a valid URL, Home Assistant raises an error. We need to catch this and show a proper validation error in the form instead.

What to do:

  • In custom_components/device_tools/config_flow.py, in both the config flow step async_step_modify_device and the options flow async_step_init, add validation of CONF_CONFIGURATION_URL when it is present in the processed user input.
  • Use voluptuous's vol.Url() validator or Python's urllib.parse.urlparse to check it's a valid URL.
  • If validation fails, re-show the form with an errors dict like {"base": "invalid_configuration_url"}.
  • Add the translation key "invalid_configuration_url" to custom_components/device_tools/translations/en.json and custom_components/device_tools/translations/de.json under the errors section.

Task 2: Gracefully handle deleted devices/entities referenced by modifications

Problem: If a device or entity referenced by a modification no longer exists in the registry, the integration can behave unexpectedly. We need to detect this at startup and handle it gracefully.

What to do in custom_components/device_tools/engine.py (ModificationEngine.async_on_entry_loaded):

  1. For ENTITY modifications where CONF_MODIFICATION_ENTRY_ID references a non-existent entity:

    • Log a warning: "Entity <id> referenced by modification '<title>' no longer exists. Disabling modification."
    • Disable the config entry by calling self._hass.config_entries.async_update_entry(config_entry, disabled_by=ConfigEntryDisabledBy.USER) — import ConfigEntryDisabledBy from homeassistant.config_entries.
    • Skip setting up handlers for this entry.
  2. For DEVICE modifications (non-creation, i.e. modification_is_custom_entry=False) where CONF_MODIFICATION_ENTRY_ID references a non-existent device:

    • Log a warning: "Device <id> referenced by modification '<title>' no longer exists. Disabling modification."
    • Disable the config entry similarly.
    • Skip setting up handlers.
  3. For MERGE modifications where a source device in CONF_MODIFICATION_ORIGINAL_DATA no longer exists:

    • Log a warning: "Device <id> referenced by merge modification '<title>' no longer exists. Removing from merge sources."
    • Update the config entry data to remove that device (and its entities) from CONF_MODIFICATION_ORIGINAL_DATA using self._hass.config_entries.async_update_entry(config_entry, data={...}).
    • Continue with the remaining devices.
  4. For DEVICE modifications where CONF_ASSIGNED_ENTITIES contains entity IDs that don't exist:

    • Log a warning: "Entity <id> assigned to device modification '<title>' no longer exists. Removing from assigned entities."
    • Update the config entry options to remove the missing entity IDs from CONF_ASSIGNED_ENTITIES.
  5. Log-once behavior: To avoid spamming the log on every restart, track already-warned entries in a set self._warned_missing: set[str] (keyed by config_entry.entry_id + "_" + missing_id). Only log if that key is not already in the set.


Task 3: Allow editing merge device IDs in the options flow

Problem: The options flow for MERGE modifications shows CONF_MERGE_DEVICE_IDS as read-only, so users cannot change which devices are merged.

What to do:

  1. In _get_merge_options_schema in config_flow.py: Remove read_only=True from the DeviceSelector for CONF_MERGE_DEVICE_IDS.

  2. In the options flow async_step_init: When modification_type == ModificationType.MERGE and the user submits the form:

    • Extract the new list of device IDs from user_input[CONF_MERGE_OPTIONS][CONF_MERGE_DEVICE_IDS].
    • Exclude the target device itself (same as in async_step_merge_device).
    • Compute new CONF_MODIFICATION_ORIGINAL_DATA: for newly-added device IDs, gather their entities from the entity registry (same logic as in async_step_merge_device). For removed device IDs, drop them.
    • Update the config entry data with the new CONF_MODIFICATION_ORIGINAL_DATA using self.hass.config_entries.async_update_entry(self.config_entry, data={...new_data...}).
    • Store the result back in modification_data (the options) appropriately.

Relevant code

config_flow.py _get_merge_options_schema (lines 431-468):

def _get_merge_options_schema(...) -> vol.Schema:
    return cast(
        vol.Sc...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

*This pull request was created from Copilot chat.*
>

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.

…e merge devices

Agent-Logs-Url: https://github.com/EuleMitKeule/device-tools/sessions/f2f108ac-e8e4-47d0-ba03-f536561f9bfa

Co-authored-by: EuleMitKeule <47421406+EuleMitKeule@users.noreply.github.com>
Copilot AI changed the title [WIP] Validate configuration_url in config flow for device-tools Add URL validation, graceful missing-device handling, and editable merge sources in options flow Mar 30, 2026
Copilot AI requested a review from EuleMitKeule March 30, 2026 02:34
@EuleMitKeule

Copy link
Copy Markdown
Owner

@copilot I think you should not only check for missing/deleted devices/entities in async_on_entry_loaded but ideally our registry listeners also listen for relevant events and get notified of such deletions that way and can then disable the relevant config entries using the same mechanism you implemented.

Copilot AI commented Mar 30, 2026

Copy link
Copy Markdown
Author

@copilot I think you should not only check for missing/deleted devices/entities in async_on_entry_loaded but ideally our registry listeners also listen for relevant events and get ...

Done in commit 0db01cd. The engine now registers two bus-level listeners in async_start (_async_on_entity_registry_updated and _async_on_device_registry_updated) that fire on action == "remove" events. They apply the same disable/cleanup logic already present in async_on_entry_loaded:

  • Entity removed: any ENTITY modification targeting that entity is disabled via async_set_disabled_by; any DEVICE modification that had it in CONF_ASSIGNED_ENTITIES has it stripped out
  • Device removed: any non-creation DEVICE modification targeting that device is disabled; any MERGE modification that listed it as a source has it removed from CONF_MODIFICATION_ORIGINAL_DATA

Both listeners are torn down in async_stop and the same _warned_missing set gates log messages to once per session.

Copilot AI changed the title Add URL validation, graceful missing-device handling, and editable merge sources in options flow Add URL validation, graceful missing-device handling with real-time listeners, and editable merge sources in options flow Mar 30, 2026
@EuleMitKeule EuleMitKeule marked this pull request as ready for review March 30, 2026 02:48
@EuleMitKeule EuleMitKeule requested a review from Copilot March 30, 2026 02:49
@sonarqubecloud

Copy link
Copy Markdown

@EuleMitKeule EuleMitKeule merged commit dbf532c into dev-2.x Mar 30, 2026
12 checks passed
@EuleMitKeule EuleMitKeule deleted the copilot/dev-2x-validate-configuration-url branch March 30, 2026 02:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the device_tools Home Assistant integration by adding user-input URL validation, making modifications resilient to deleted registry items (at startup and in real time), and allowing MERGE source devices to be edited in the options flow.

Changes:

  • Add configuration_url validation in config flow + options flow with a new invalid_configuration_url translation key.
  • Add startup-time cleanup/disable behavior for stale entity/device references and add engine-level registry “remove” listeners to react in real time.
  • Allow editing MERGE source device IDs in options flow and recompute persisted merge original data accordingly.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
custom_components/device_tools/config_flow.py Adds URL validation helper, validates configuration_url in flows, and makes MERGE device selector editable + recomputes merge original data on options save.
custom_components/device_tools/engine.py Adds startup-time stale-reference handling and registers engine-wide entity/device registry “remove” listeners with log-once behavior.
custom_components/device_tools/translations/en.json Adds the new invalid_configuration_url error text and updates merge-source label to reflect editability.
custom_components/device_tools/translations/de.json Adds the new invalid_configuration_url error text and updates merge-source label to reflect editability.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +93 to +96
"""Return True if *value* is a valid absolute URL with a scheme and host."""
try:
parsed = urllib.parse.urlparse(value)
return bool(parsed.scheme and parsed.netloc)

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_is_valid_url() currently accepts any scheme as long as scheme and netloc are present, which would treat values like javascript://... as valid. Since configuration_url is typically rendered as a clickable link in the UI, please restrict allowed schemes (e.g., only http/https) to avoid potentially unsafe URL schemes being persisted.

Suggested change
"""Return True if *value* is a valid absolute URL with a scheme and host."""
try:
parsed = urllib.parse.urlparse(value)
return bool(parsed.scheme and parsed.netloc)
"""Return True if *value* is a valid absolute HTTP/HTTPS URL with a scheme and host."""
try:
parsed = urllib.parse.urlparse(value)
if not (parsed.scheme and parsed.netloc):
return False
return parsed.scheme.lower() in ("http", "https")

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +236
mod_type = ModificationType(config_entry.data[CONF_MODIFICATION_TYPE])
modification_entry_id: str | None = config_entry.data.get(CONF_MODIFICATION_ENTRY_ID)
modification_is_custom_entry: bool = config_entry.data.get(
CONF_MODIFICATION_IS_CUSTOM_ENTRY, False
)
title: str = config_entry.title

entity_registry = er.async_get(self._hass)
device_registry = dr.async_get(self._hass)

# --- Graceful handling of deleted targets / sources ---

# ENTITY modification: check that the referenced entity still exists
if mod_type == ModificationType.ENTITY and modification_entry_id:
if entity_registry.async_get(modification_entry_id) is None:
warn_key = f"{config_entry.entry_id}_{modification_entry_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Entity %s referenced by modification '%s' no longer exists. "
"Disabling modification.",
modification_entry_id,
title,
)
await self._hass.config_entries.async_set_disabled_by(
config_entry.entry_id,
ConfigEntryDisabler.USER,
)
return

# DEVICE modification (non-creation): check that the referenced device still exists
if (
mod_type == ModificationType.DEVICE
and not modification_is_custom_entry
and modification_entry_id
):
if device_registry.async_get(modification_entry_id) is None:
warn_key = f"{config_entry.entry_id}_{modification_entry_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Device %s referenced by modification '%s' no longer exists. "
"Disabling modification.",
modification_entry_id,
title,
)
await self._hass.config_entries.async_set_disabled_by(
config_entry.entry_id,
ConfigEntryDisabler.USER,
)
return

# MERGE modification: remove any source devices that no longer exist
if mod_type == ModificationType.MERGE:
original_data: dict[str, Any] = config_entry.data.get(
CONF_MODIFICATION_ORIGINAL_DATA, {}
)
missing_sources = [
device_id
for device_id in original_data
if device_registry.async_get(device_id) is None
]
if missing_sources:
new_original_data = {
k: v for k, v in original_data.items() if k not in missing_sources
}
for missing_id in missing_sources:
warn_key = f"{config_entry.entry_id}_{missing_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Device %s referenced by merge modification '%s' no longer "
"exists. Removing from merge sources.",
missing_id,
title,
)
self._hass.config_entries.async_update_entry(
config_entry,
data={
**config_entry.data,
CONF_MODIFICATION_ORIGINAL_DATA: new_original_data,
},
)
# config_entry is mutated in-place by HA; re-read data from it
config_entry = self._hass.config_entries.async_get_entry(
config_entry.entry_id
) or config_entry

# DEVICE modification: remove assigned entities that no longer exist
if mod_type == ModificationType.DEVICE:
mod_data: dict[str, Any] = config_entry.options.get(CONF_MODIFICATION_DATA, {})
assigned: list[str] = mod_data.get(CONF_ASSIGNED_ENTITIES, [])
missing_entities = [
eid for eid in assigned if entity_registry.async_get(eid) is None
]
if missing_entities:
new_assigned = [eid for eid in assigned if eid not in missing_entities]
for missing_id in missing_entities:
warn_key = f"{config_entry.entry_id}_{missing_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Entity %s assigned to device modification '%s' no longer "
"exists. Removing from assigned entities.",
missing_id,
title,
)
new_mod_data = {**mod_data, CONF_ASSIGNED_ENTITIES: new_assigned}
new_options = {
**config_entry.options,
CONF_MODIFICATION_DATA: new_mod_data,
}
self._hass.config_entries.async_update_entry(
config_entry,
options=new_options,
)
config_entry = self._hass.config_entries.async_get_entry(
config_entry.entry_id
) or config_entry

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new startup-time cleanup/disabling logic for stale entity/device references changes engine behavior significantly, but there are currently no tests covering these cases in tests/test_engine.py. Please add tests that assert the correct config-entry updates (disable ENTITY/DEVICE entries; strip missing merge sources; remove missing assigned entities) and that handlers are not created/applied for disabled entries.

Copilot uses AI. Check for mistakes.
Comment on lines +668 to +791
async def _async_on_entity_registry_updated(
self,
event: Event[er.EventEntityRegistryUpdatedData],
) -> None:
"""React to entity-registry events at the engine level.

Handles ``action == "remove"``: an entity that was the *target* of an
ENTITY modification is disabled; an entity that was *assigned* to a
DEVICE modification is removed from its ``CONF_ASSIGNED_ENTITIES`` list.
All other actions are ignored here (individual entity handlers react to
"update" actions themselves).
"""
if event.data["action"] != "remove":
return
removed_entity_id: str = event.data["entity_id"]

for entry in list(self._tracked_entries.values()):
mod_type = ModificationType(entry.data[CONF_MODIFICATION_TYPE])
entry_target_id: str | None = entry.data.get(CONF_MODIFICATION_ENTRY_ID)

if mod_type == ModificationType.ENTITY and entry_target_id == removed_entity_id:
warn_key = f"{entry.entry_id}_{removed_entity_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Entity %s referenced by modification '%s' no longer exists. "
"Disabling modification.",
removed_entity_id,
entry.title,
)
await self._hass.config_entries.async_set_disabled_by(
entry.entry_id,
ConfigEntryDisabler.USER,
)

elif mod_type == ModificationType.DEVICE:
mod_data: dict[str, Any] = entry.options.get(CONF_MODIFICATION_DATA, {})
assigned: list[str] = mod_data.get(CONF_ASSIGNED_ENTITIES, [])
if removed_entity_id in assigned:
new_assigned = [eid for eid in assigned if eid != removed_entity_id]
warn_key = f"{entry.entry_id}_{removed_entity_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Entity %s assigned to device modification '%s' no longer "
"exists. Removing from assigned entities.",
removed_entity_id,
entry.title,
)
new_mod_data = {**mod_data, CONF_ASSIGNED_ENTITIES: new_assigned}
self._hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_MODIFICATION_DATA: new_mod_data},
)

async def _async_on_device_registry_updated(
self,
event: Event[dr.EventDeviceRegistryUpdatedData],
) -> None:
"""React to device-registry events at the engine level.

Handles ``action == "remove"``: a device that was the *target* of a
non-creation DEVICE modification is disabled; a device that was a
*source* in a MERGE modification is removed from
``CONF_MODIFICATION_ORIGINAL_DATA``.
All other actions are ignored here (individual device handlers react
to "update" actions themselves).
"""
if event.data["action"] != "remove":
return
removed_device_id: str = event.data["device_id"]

for entry in list(self._tracked_entries.values()):
mod_type = ModificationType(entry.data[CONF_MODIFICATION_TYPE])
entry_target_id: str | None = entry.data.get(CONF_MODIFICATION_ENTRY_ID)
modification_is_custom_entry: bool = entry.data.get(
CONF_MODIFICATION_IS_CUSTOM_ENTRY, False
)

if (
mod_type == ModificationType.DEVICE
and not modification_is_custom_entry
and entry_target_id == removed_device_id
):
warn_key = f"{entry.entry_id}_{removed_device_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Device %s referenced by modification '%s' no longer exists. "
"Disabling modification.",
removed_device_id,
entry.title,
)
await self._hass.config_entries.async_set_disabled_by(
entry.entry_id,
ConfigEntryDisabler.USER,
)

elif mod_type == ModificationType.MERGE:
original_data: dict[str, Any] = entry.data.get(
CONF_MODIFICATION_ORIGINAL_DATA, {}
)
if removed_device_id in original_data:
new_original_data = {
k: v
for k, v in original_data.items()
if k != removed_device_id
}
warn_key = f"{entry.entry_id}_{removed_device_id}"
if warn_key not in self._warned_missing:
self._warned_missing.add(warn_key)
_LOGGER.warning(
"Device %s referenced by merge modification '%s' no longer "
"exists. Removing from merge sources.",
removed_device_id,
entry.title,
)
self._hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_MODIFICATION_ORIGINAL_DATA: new_original_data,
},
)

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new engine-level registry "remove" listeners update/disable config entries in response to entity/device deletions, but there are no tests verifying these real-time behaviors. Please add tests that fire EVENT_ENTITY_REGISTRY_UPDATED / EVENT_DEVICE_REGISTRY_UPDATED with action == "remove" and assert the expected config entry updates (disable target mods, strip assigned entities, remove merge sources) and that the engine reacts without errors.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants