Skip to content

feat: Add configuration_url, model_id, entry_type, connections, and identifiers to device modifications#70

Merged
EuleMitKeule merged 8 commits into
dev-2.xfrom
copilot/add-device-registry-attributes
Mar 27, 2026
Merged

feat: Add configuration_url, model_id, entry_type, connections, and identifiers to device modifications#70
EuleMitKeule merged 8 commits into
dev-2.xfrom
copilot/add-device-registry-attributes

Conversation

Copilot AI commented Mar 27, 2026

Copy link
Copy Markdown

Extends device modifications to support five additional async_update_device attributes that were previously inaccessible: configuration_url, model_id, entry_type, connections, and identifiers.

Changes

  • const.py — New constants for all five attributes; added to MODIFIABLE_ATTRIBUTES[DEVICE]; removed connections and identifiers from IGNORED_ATTRIBUTES so original values are captured and reverted correctly

  • config_flow.py — New form fields: configuration_url / model_id as plain str, entry_type as SelectSelector ("none" / "service"), connections / identifiers as ObjectSelector (user enters list-of-[type, value] pairs); added _normalize_device_value() to convert frozenset/DeviceEntryType values from device.dict_repr to JSON-safe types before storing in config entry data; added _check_connections_collision() that validates each supplied connection against the device registry before saving — if a connection is already claimed by a different device, both the config flow (async_step_modify_device) and options flow (async_step_init) re-display the form with a connections_collision error and the conflicting device name interpolated via description_placeholders

  • entry_handler.py — Added DeviceHandler._prepare_device_kwargs() static helper that normalises the three non-trivial types before passing to async_update_device:

    • entry_type: "none" | "service" string or existing DeviceEntryTypeDeviceEntryType enum or None
    • connections / identifiers: list-of-lists or set-of-tuples → new_connections / new_identifiers as set[tuple[str, str]]

    Helper is shared by both async_apply() and async_revert().

  • translations/en.json / translations/de.json — Labels, descriptions, and entry_type selector options added to both config and options flows; connections_collision error key added to config.error and options.error in both language files

Example — connections/identifiers input format (ObjectSelector)

[["mac", "aa:bb:cc:dd:ee:ff"], ["zigbee", "0x1234abcd"]]

Stored as-is in config entry options; converted to set[tuple[str, str]] on apply/revert.

Connections collision guard

If a connection entered by the user is already registered on a different device in the registry, the flow re-displays the form with:

"This connection is already registered with device "{device}". To merge devices, please use a Merge modification instead."

This prevents DeviceConnectionCollisionError from surfacing at setup time.

Original prompt

Summary

Add support for modifying additional device registry attributes in Device modifications:

  • configuration_url — the URL shown in the device info panel
  • model_id — machine-readable model identifier
  • entry_type — whether the device is a "service" or default (None)
  • connections — set of (type, value) tuples (e.g. MAC address)
  • identifiers — set of (domain, id) tuples

Files to change

custom_components/device_tools/const.py

Add new constants:

CONF_CONFIGURATION_URL = "configuration_url"
CONF_MODEL_ID = "model_id"
CONF_ENTRY_TYPE = "entry_type"
CONF_CONNECTIONS = "connections"
CONF_IDENTIFIERS = "identifiers"

Add them to MODIFIABLE_ATTRIBUTES[ModificationType.DEVICE]:

MODIFIABLE_ATTRIBUTES = {
    ModificationType.DEVICE: [
        CONF_MANUFACTURER,
        CONF_MODEL,
        CONF_MODEL_ID,
        CONF_SW_VERSION,
        CONF_HW_VERSION,
        CONF_SERIAL_NUMBER,
        CONF_VIA_DEVICE_ID,
        CONF_CONFIGURATION_URL,
        CONF_ENTRY_TYPE,
        CONF_CONNECTIONS,
        CONF_IDENTIFIERS,
    ],
    ...
}

Also remove "connections" and "identifiers" from IGNORED_ATTRIBUTES since they will now be tracked and reverted.


custom_components/device_tools/config_flow.py

Import the new constants from .const.

In _get_device_options_schema, add the following fields inside the device_attributes section:

configuration_url — plain str field:

vol.Optional(
    CONF_CONFIGURATION_URL,
    description={
        "suggested_value": modification_data.get(
            CONF_CONFIGURATION_URL,
            modification_original_data.get(CONF_CONFIGURATION_URL),
        )
    },
): str,

model_id — plain str field:

vol.Optional(
    CONF_MODEL_ID,
    description={
        "suggested_value": modification_data.get(
            CONF_MODEL_ID,
            modification_original_data.get(CONF_MODEL_ID),
        )
    },
): str,

entry_typeSelectSelector with options "" (none/default) and "service":

vol.Optional(
    CONF_ENTRY_TYPE,
    description={
        "suggested_value": modification_data.get(
            CONF_ENTRY_TYPE,
            modification_original_data.get(CONF_ENTRY_TYPE),
        )
    },
): selector.SelectSelector(
    selector.SelectSelectorConfig(
        options=["", "service"],
        mode=selector.SelectSelectorMode.DROPDOWN,
        translation_key=CONF_ENTRY_TYPE,
    )
),

connectionsObjectSelector:

vol.Optional(
    CONF_CONNECTIONS,
    description={
        "suggested_value": modification_data.get(
            CONF_CONNECTIONS,
            modification_original_data.get(CONF_CONNECTIONS),
        )
    },
): selector.ObjectSelector(),

identifiersObjectSelector:

vol.Optional(
    CONF_IDENTIFIERS,
    description={
        "suggested_value": modification_data.get(
            CONF_IDENTIFIERS,
            modification_original_data.get(CONF_IDENTIFIERS),
        )
    },
): selector.ObjectSelector(),

Also update _user_input_to_modification_data to filter out empty strings (same as what the entity_category PR does):

return {
    k: v
    for k, v in user_input.items()
    if v is not None
    and v != ""
    and v != modification_original_data.get(k)
    and k in MODIFIABLE_ATTRIBUTES[modification_type]
}

custom_components/device_tools/device_modification.py

The apply() method currently does **self.modification_data directly. We need to handle special fields before passing to async_update_device:

  • entry_type: stored as "" or "service" string → convert to DeviceEntryType("service") or None
  • connections: stored as a list of lists from ObjectSelector (JSON) → convert to set[tuple[str, str]] and pass as new_connections
  • identifiers: stored as a list of lists from ObjectSelector (JSON) → convert to set[tuple[str, str]] and pass as new_identifiers
  • configuration_url, model_id: pass through as-is

Update apply():

async def apply(self) -> None:
    """Apply modification."""
    self._listener.unregister_callback(
        self.modification_entry_id,
        self._on_entry_updated,
    )
    data = dict(self.modification_data)

    # Handle entry_type conversion
    if CONF_ENTRY_TYPE in data:
        raw = data.pop(CONF_ENTRY_TYPE)
        data[CONF_ENTRY_TYPE] = dr.DeviceEntryType(raw) if raw else None

    # Handle connections: stored as list-of-lists, pass as new_connections
    if CONF_CONNECTIONS in data:
        raw = data.pop(CONF_CONNECTIONS)
        data["new_connections"] = {tuple(c) for c in raw} if raw else set()

    # Handle identifiers: stored as list-of-lists, pass as new_identifiers
    if CONF_IDENTIFIERS in data:
        raw = data.pop(CONF_IDENTIFIERS)
        data["new_identifiers"] = {tuple(i) for i in raw} if raw else set()

    _LOGGER.debug(
        "Applying device modificatio...

</details>



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

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

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

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security)

…ifiers to device modifications

Agent-Logs-Url: https://github.com/EuleMitKeule/device-tools/sessions/53fd009a-1a3c-487d-aa04-7ec2a3ec11ab

Co-authored-by: EuleMitKeule <47421406+EuleMitKeule@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for modifying device registry attributes feat: Add configuration_url, model_id, entry_type, connections, and identifiers to device modifications Mar 27, 2026
Copilot AI requested a review from EuleMitKeule March 27, 2026 00:33
@EuleMitKeule

Copy link
Copy Markdown
Owner

@copilot fix the hassfest issue

…o satisfy hassfest translation key validation

Agent-Logs-Url: https://github.com/EuleMitKeule/device-tools/sessions/0186196a-9bc7-480a-9613-6a94b0d9f0b2

Co-authored-by: EuleMitKeule <47421406+EuleMitKeule@users.noreply.github.com>

Copilot AI commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot fix the hassfest issue

Fixed in 4078005. The hassfest error was that "" is not a valid translation key (must match [a-z0-9-_]+). Replaced the empty string option with "none" throughout — selector options, suggested value computation, _prepare_device_kwargs conversion ("none"None), and the translations file.

@EuleMitKeule

Copy link
Copy Markdown
Owner

@copilot

2026-03-27 00:55:35.397 ERROR (MainThread) [homeassistant] Error doing job: Task exception was never retrieved (task: None):   File "/home/vscode/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu/lib/python3.14/runpy.py", line 198, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/home/vscode/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu/lib/python3.14/runpy.py", line 88, in _run_code
    exec(code, run_globals)
  File "/home/vscode/.vscode-server/extensions/ms-python.debugpy-2025.18.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/__main__.py", line 71, in <module>
    cli.main()
  File "/home/vscode/.vscode-server/extensions/ms-python.debugpy-2025.18.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 508, in main
    run()
  File "/home/vscode/.vscode-server/extensions/ms-python.debugpy-2025.18.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher/../../debugpy/../debugpy/server/cli.py", line 391, in run_module
    run_module_as_main(options.target, alter_argv=True)
  File "/home/vscode/.vscode-server/extensions/ms-python.debugpy-2025.18.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 228, in _run_module_as_main
    return _run_code(code, main_globals, None, "__main__", mod_spec)
  File "/home/vscode/.vscode-server/extensions/ms-python.debugpy-2025.18.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_runpy.py", line 118, in _run_code
    exec(code, run_globals)
  File "/workspaces/core/homeassistant/__main__.py", line 229, in <module>
    sys.exit(main())
  File "/workspaces/core/homeassistant/__main__.py", line 215, in main
    exit_code = runner.run(runtime_conf)
  File "/workspaces/core/homeassistant/runner.py", line 289, in run
    return loop.run_until_complete(setup_and_run_hass(runtime_config))
  File "/home/vscode/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu/lib/python3.14/asyncio/base_events.py", line 706, in run_until_complete
    self.run_forever()
  File "/home/vscode/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu/lib/python3.14/asyncio/base_events.py", line 677, in run_forever
    self._run_once()
  File "/home/vscode/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu/lib/python3.14/asyncio/base_events.py", line 2038, in _run_once
    handle._run()
  File "/home/vscode/.local/share/uv/python/cpython-3.14.3-linux-x86_64-gnu/lib/python3.14/asyncio/events.py", line 94, in _run
    self._context.run(self._callback, *self._args)
  File "/home/vscode/.local/ha-venv/lib/python3.14/site-packages/aiohttp/web_protocol.py", line 598, in start
    task = asyncio.Task(coro, loop=loop, eager_start=True)
  File "/home/vscode/.local/ha-venv/lib/python3.14/site-packages/aiohttp/web_protocol.py", line 510, in _handle_request
    resp = await request_handler(request)
  File "/home/vscode/.local/ha-venv/lib/python3.14/site-packages/aiohttp/web_app.py", line 569, in _handle
    return await handler(request)
  File "/home/vscode/.local/ha-venv/lib/python3.14/site-packages/aiohttp/web_middlewares.py", line 117, in impl
    return await handler(request)
  File "/workspaces/core/homeassistant/components/http/security_filter.py", line 92, in security_filter_middleware
    return await handler(request)
  File "/workspaces/core/homeassistant/components/http/forwarded.py", line 87, in forwarded_middleware
    return await handler(request)
  File "/workspaces/core/homeassistant/components/http/request_context.py", line 26, in request_context_middleware
    return await handler(request)
  File "/workspaces/core/homeassistant/components/http/ban.py", line 86, in ban_middleware
    return await handler(request)
  File "/workspaces/core/homeassistant/components/http/auth.py", line 242, in auth_middleware
    return await handler(request)
  File "/workspaces/core/homeassistant/components/http/headers.py", line 41, in headers_middleware
    response = await handler(request)
  File "/workspaces/core/homeassistant/helpers/http.py", line 73, in handle
    result = await handler(request, **request.match_info)
  File "/workspaces/core/homeassistant/components/http/decorators.py", line 83, in with_admin
    return await func(self, request, *args, **kwargs)
  File "/workspaces/core/homeassistant/components/config/config_entries.py", line 291, in post
    return await super().post(request, flow_id)
  File "/workspaces/core/homeassistant/components/http/data_validator.py", line 74, in wrapper
    return await method(view, request, data, *args, **kwargs)
  File "/workspaces/core/homeassistant/helpers/data_entry_flow.py", line 121, in post
    result = await self._flow_mgr.async_configure(flow_id, data)
  File "/workspaces/core/homeassistant/data_entry_flow.py", line 336, in async_configure
    result = await self._async_configure(flow_id, user_input)
  File "/workspaces/core/homeassistant/data_entry_flow.py", line 383, in _async_configure
    result = await self._async_handle_step(
  File "/workspaces/core/homeassistant/data_entry_flow.py", line 544, in _async_handle_step
    result = await self.async_finish_flow(flow, result.copy())
  File "/workspaces/core/homeassistant/config_entries.py", line 3733, in async_finish_flow
    self.hass.config_entries.async_update_entry(
  File "/workspaces/core/homeassistant/config_entries.py", line 2381, in async_update_entry
    return self._async_update_entry(
  File "/workspaces/core/homeassistant/config_entries.py", line 2484, in _async_update_entry
    self._async_save_and_notify(entry)
  File "/workspaces/core/homeassistant/config_entries.py", line 2490, in _async_save_and_notify
    self.hass.async_create_task(
  File "/workspaces/core/homeassistant/core.py", line 772, in async_create_task
    return self.async_create_task_internal(target, name, eager_start)
  File "/workspaces/core/homeassistant/core.py", line 794, in async_create_task_internal
    task = create_eager_task(target, name=name, loop=self.loop)
  File "/workspaces/core/homeassistant/util/async_.py", line 44, in create_eager_task
    return Task(coro, loop=loop, name=name, eager_start=True)
Traceback (most recent call last):
  File "/workspaces/core/config/custom_components/device_tools/__init__.py", line 163, in update_listener
    await device_tools_data.engine.async_on_entry_updated(config_entry)
  File "/workspaces/core/config/custom_components/device_tools/engine.py", line 283, in async_on_entry_updated
    await device_handler.async_apply(entries)
  File "/workspaces/core/config/custom_components/device_tools/entry_handler.py", line 291, in async_apply
    device_registry.async_update_device(
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        self._entry_id, **self._prepare_device_kwargs(merged)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/workspaces/core/homeassistant/helpers/device_registry.py", line 1359, in async_update_device
    return self._async_update_device(
           ~~~~~~~~~~~~~~~~~~~~~~~~~^
        device_id,
        ^^^^^^^^^^
    ...<23 lines>...
        via_device_id=via_device_id,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/workspaces/core/homeassistant/helpers/device_registry.py", line 1236, in _async_update_device
    added_connections = new_values["connections"] = self._validate_connections(
                                                    ~~~~~~~~~~~~~~~~~~~~~~~~~~^
        device_id, new_connections, False
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "/workspaces/core/homeassistant/helpers/device_registry.py", line 1406, in _validate_connections
    raise DeviceConnectionCollisionError(
        normalized_connections, existing_device
    )
homeassistant.helpers.device_registry.DeviceConnectionCollisionError: Connections {('bluetooth', '24:D7:EB:10:D4:32')} already registered with DeviceEntry(area_id=None, config_entries={'01K55MNEMNS2VSPWCF2QX5BQ8W', '01KMPCM0WKNBQXNT744A8S3DDQ'}, config_entries_subentries={'01K55MNEMNS2VSPWCF2QX5BQ8W': {None}, '01KMPCM0WKNBQXNT744A8S3DDQ': {None}}, configuration_url=None, connections={('bluetooth', '24:D7:EB:10:D4:32')}, created_at=datetime.datetime(2025, 9, 15, 2, 53, 32, 693397, tzinfo=datetime.timezone.utc), disabled_by=None, entry_type=None, hw_version=None, id='ba6a5d788b118c45cddc3bdbc638ca8d', identifiers=set(), labels=set(), manufacturer='Espressif Inc. (esphome)', model='nodemcu-32s', model_id=None, modified_at=datetime.datetime(2026, 3, 27, 0, 55, 29, 946286, tzinfo=datetime.timezone.utc), name_by_user=None, name='Esszimmer Bluetooth (24:D7:EB:10:D4:32)', primary_config_entry='01K55MNEMNS2VSPWCF2QX5BQ8W', serial_number=None, _suggested_area=None, sw_version=None, via_device_id='f797f148de1e2b54a6b260a2751b18a5', _cache={'json_repr': b'{"area_id":null,"configuration_url":null,"config_entries":["01K55MNEMNS2VSPWCF2QX5BQ8W","01KMPCM0WKNBQXNT744A8S3DDQ"],"config_entries_subentries":{"01K55MNEMNS2VSPWCF2QX5BQ8W":[null],"01KMPCM0WKNBQXNT744A8S3DDQ":[null]},"connections":[["bluetooth","24:D7:EB:10:D4:32"]],"created_at":1757904812.693397,"disabled_by":null,"entry_type":null,"hw_version":null,"id":"ba6a5d788b118c45cddc3bdbc638ca8d","identifiers":[],"labels":[],"manufacturer":"Espressif Inc. (esphome)","model":"nodemcu-32s","model_id":null,"modified_at":1774572929.946286,"name_by_user":null,"name":"Esszimmer Bluetooth (24:D7:EB:10:D4:32)","primary_config_entry":"01K55MNEMNS2VSPWCF2QX5BQ8W","serial_number":null,"sw_version":null,"via_device_id":"f797f148de1e2b54a6b260a2751b18a5"}'})

This happens when trying to assign to a device a bluetooth mac that is already present in the connections of another device. Shouldn't this work? I thought this would lead to home assistant natively merging the devices?

Another problem is that the object selectors have this weird double dash:

grafik

It seems to work but it does not look right? Why is that there

… form render and config entry storage

Agent-Logs-Url: https://github.com/EuleMitKeule/device-tools/sessions/3cfac61e-3865-40ed-891d-d43ba7377ac9

Co-authored-by: EuleMitKeule <47421406+EuleMitKeule@users.noreply.github.com>

Copilot AI commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot

2026-03-27 00:55:35.397 ERROR (MainThread) [homeassistant] Error doing job: Task exception was never retrieved (task: None):   File "/home/vscode/.local/share/uv/py...

Fixed in 1ad02de. The Task exception was never retrieved error was caused by device.dict_repr returning connections/identifiers as frozenset[tuple[str, str]] and entry_type as a DeviceEntryType enum — neither is JSON-serializable, so storing them in config entry data (as CONF_MODIFICATION_ORIGINAL_DATA) crashed. Added _normalize_device_value() to convert these to list-of-lists / string values before form rendering and config entry storage.

@EuleMitKeule

Copy link
Copy Markdown
Owner

@copilot When a user configures connections or identifiers in the config/options flow, we need to validate in the flow step itself (before saving) whether any of the specified connections are already registered on a different device in the device registry.

If a collision is detected, the flow should return a form error instead of saving — so the user never hits the DeviceConnectionCollisionError at setup time.

What to implement

In config_flow.py, during the step that processes connections (e.g. async_step_device or wherever _user_input_to_modification_data is called and the result is about to be saved), add a validation block like this:

if CONF_CONNECTIONS in modification_data:
    device_registry = dr.async_get(self.hass)
    target_device_id = ...  # the device being modified
    new_connections = {tuple(c) for c in modification_data[CONF_CONNECTIONS]}

    for connection in new_connections:
        existing_device = device_registry.async_get_device(connections={connection})
        if existing_device is not None and existing_device.id != target_device_id:
            errors["connections"] = "connections_collision"
            # Store the conflicting device name for the error message
            self._connections_collision_device = existing_device.name or existing_device.id
            return self.async_show_form(..., errors=errors)

Error message

Add a translation key connections_collision to translations/en.json that includes the name of the conflicting device. Something like:

"connections_collision": "This connection is already registered with device \"{device}\". To merge devices, please use a Merge modification instead."

The device name should be interpolated dynamically into the error string (or shown as a separate description placeholder if HA's translation system doesn't support dynamic error strings — in that case, store the name in self._connections_collision_device and pass it as a description_placeholders to async_show_form).

…onfig/options flow

Agent-Logs-Url: https://github.com/EuleMitKeule/device-tools/sessions/4a422895-2100-4379-901b-cba1812f99fd

Co-authored-by: EuleMitKeule <47421406+EuleMitKeule@users.noreply.github.com>

Copilot AI commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot When a user configures connections or identifiers in the config/options flow, we need to validate in the flow step itself (before saving) whether any of the specified connections are alre...

Done in 67d324c. Added _check_connections_collision() which iterates over the user-supplied connections and calls device_registry.async_get_device(connections={...}) for each one. If any connection is claimed by a device other than the target, the function returns that conflicting device.

Validation is wired into both flows:

  • Config flow (async_step_modify_device): checked after _user_input_to_modification_data, re-shows the form with the user's input preserved as suggested values
  • Options flow (async_step_init): checked after _options_flow_user_input_to_modification_data, same re-show behavior

Both use errors={"base": "connections_collision"} and description_placeholders={"device": colliding.name or colliding.id} so the error message includes the conflicting device name. Translation key added to config.error and options.error in both en.json and de.json.

@EuleMitKeule EuleMitKeule marked this pull request as ready for review March 27, 2026 01:48
@EuleMitKeule EuleMitKeule requested a review from Copilot March 27, 2026 01:48

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 extends the Device Tools “device modification” flow to allow modifying additional Home Assistant device-registry fields (configuration_url, model_id, entry_type, connections, identifiers) and adds a guard to prevent connection collisions with other devices.

Changes:

  • Add new modifiable-attribute constants and expose them in the device modification attribute set.
  • Extend config/options flows with new fields, including normalization of registry values into JSON-safe config-entry data and a connection-collision validation step.
  • Normalize and map new attributes into async_update_device-compatible kwargs during apply/revert.

Reviewed changes

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

Show a summary per file
File Description
custom_components/device_tools/const.py Adds new constants and includes them in device modifiable attributes; un-ignores connections/identifiers.
custom_components/device_tools/config_flow.py Adds UI fields for the new attributes, normalization for JSON-safe storage, and connection collision checking.
custom_components/device_tools/entry_handler.py Adds a helper to convert stored values into async_update_device kwargs (enum + connection/identifier set conversion).
custom_components/device_tools/translations/en.json Adds labels/descriptions, selector option labels, and the new collision error string.
custom_components/device_tools/translations/de.json Adds labels/descriptions and the collision error string (but currently missing selector option labels for entry_type).

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

Comment on lines +247 to +253
if CONF_CONNECTIONS in result:
raw = result.pop(CONF_CONNECTIONS)
result["new_connections"] = {tuple(c) for c in raw} if raw else set()

if CONF_IDENTIFIERS in result:
raw = result.pop(CONF_IDENTIFIERS)
result["new_identifiers"] = {tuple(i) for i in raw} if raw else set()

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

_prepare_device_kwargs assumes connections/identifiers are iterables of 2-item pairs; {tuple(c) for c in raw} will raise TypeError if an element is not iterable (possible with ObjectSelector free-form input) and can also produce tuples of unexpected length. Consider validating/coercing each item (ensure list/tuple of length 2, cast both elements to str) and either filtering invalid items or raising a controlled validation error earlier in the config/options flow.

Suggested change
if CONF_CONNECTIONS in result:
raw = result.pop(CONF_CONNECTIONS)
result["new_connections"] = {tuple(c) for c in raw} if raw else set()
if CONF_IDENTIFIERS in result:
raw = result.pop(CONF_IDENTIFIERS)
result["new_identifiers"] = {tuple(i) for i in raw} if raw else set()
def _normalize_pair_set(raw_value: Any, field_name: str) -> set[tuple[str, str]]:
"""Normalize a raw iterable of pairs into a set of (str, str) tuples.
Invalid entries (non-iterables, wrong length) are ignored with a warning.
"""
normalized: set[tuple[str, str]] = set()
if not raw_value:
return normalized
for index, item in enumerate(raw_value):
if not isinstance(item, (list, tuple)) or len(item) != 2:
_LOGGER.warning(
"Ignoring invalid %s entry at index %s: %r",
field_name,
index,
item,
)
continue
first, second = item
normalized.add((str(first), str(second)))
return normalized
if CONF_CONNECTIONS in result:
raw = result.pop(CONF_CONNECTIONS)
result["new_connections"] = _normalize_pair_set(raw, CONF_CONNECTIONS)
if CONF_IDENTIFIERS in result:
raw = result.pop(CONF_IDENTIFIERS)
result["new_identifiers"] = _normalize_pair_set(raw, CONF_IDENTIFIERS)

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +190
"via_device_id": "Übergeordnetes Gerät",
"configuration_url": "Konfigurations-URL",
"entry_type": "Eintragstyp",
"connections": "Verbindungen",
"identifiers": "Bezeichner"

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

The entry_type selector uses translation_key=entry_type with options none/service, but de.json currently lacks the corresponding selector.entry_type.options translations (present in en.json). Add selector.entry_type.options.none and .service to avoid untranslated option values in the German UI.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +67
CONF_ENTRY_TYPE,
CONF_CONNECTIONS,
CONF_IDENTIFIERS,

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

Adding entry_type / connections / identifiers to MODIFIABLE_ATTRIBUTES means other parts of the integration (notably the engine’s seeding of OriginalDataStore from device.dict_repr) will now persist DeviceEntryType and frozenset[tuple[str, str]] values. Home Assistant Store requires JSON-serializable data, so this will likely raise serialization errors when saving. Consider normalizing these values (e.g., entry_type -> value string, connections/identifiers -> list-of-[type,value] lists) before writing to OriginalDataStore (and similarly when updating it from device-registry update events).

Suggested change
CONF_ENTRY_TYPE,
CONF_CONNECTIONS,
CONF_IDENTIFIERS,
# NOTE: CONF_ENTRY_TYPE, CONF_CONNECTIONS, and CONF_IDENTIFIERS are
# intentionally excluded here because their native Home Assistant
# types (e.g. DeviceEntryType, frozenset[tuple[str, str]]) are not
# JSON-serializable and can cause Store serialization failures when
# persisted via OriginalDataStore.

Copilot uses AI. Check for mistakes.
"""
if key in (CONF_CONNECTIONS, CONF_IDENTIFIERS):
if isinstance(value, (set, frozenset)):
return [list(pair) for pair in value]

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

_normalize_device_value converts set/frozenset to a list via iteration, which is non-deterministic. Because this output is used for suggested values and for equality comparisons against user input, the unstable ordering can cause spurious “changes” (or noisy option updates) even when the underlying set is the same. Consider sorting the pairs (e.g., by type/value) before returning the list-of-lists so the representation is stable across runs.

Suggested change
return [list(pair) for pair in value]
# Ensure deterministic ordering when normalizing sets/frozensets of pairs
sorted_pairs = sorted(
value,
key=lambda pair: (str(pair[0]), str(pair[1])),
)
return [list(pair) for pair in sorted_pairs]

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +79
for connection in connections:
if not isinstance(connection, (list, tuple)) or len(connection) != 2:
continue
conn_type, conn_val = connection
existing = device_registry.async_get_device(
connections={(str(conn_type), str(conn_val))}
)
if existing is not None and existing.id != target_device_id:
return existing
return None

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

_check_connections_collision silently skips malformed items (non-2-tuples/lists). However, the same malformed data can still be saved and later cause crashes when applying/reverting (e.g., _prepare_device_kwargs does tuple(c) which raises for non-iterables). It would be safer to validate connections in the flow (require a list of 2-item pairs) and surface a form error when the format is invalid, instead of allowing invalid data through.

Copilot uses AI. Check for mistakes.
@EuleMitKeule

Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

…erministically, validate format, add de translations

Agent-Logs-Url: https://github.com/EuleMitKeule/device-tools/sessions/6d0abb27-67cf-4d22-ba48-8311a8d578a5

Co-authored-by: EuleMitKeule <47421406+EuleMitKeule@users.noreply.github.com>

Copilot AI commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot apply changes based on the comments in this thread

Applied in 053abed. Changes from all threads in that review:

  • entry_handler.py: Replaced bare {tuple(c) for c in raw} with a _normalize_pair_set local helper that validates each item (must be a 2-element list/tuple), emits a _LOGGER.warning for invalid entries, and casts both elements to str.
  • config_flow.py (_normalize_device_value): Pairs from device.dict_repr are now sorted by (type, value) before being returned as list-of-lists, ensuring a stable, deterministic representation. Key function is also guarded to skip malformed pairs.
  • config_flow.py (_connections_have_invalid_format): New helper that checks for malformed entries. Both async_step_modify_device and options-flow async_step_init now show a connections_invalid_format error before reaching the collision check, preventing invalid data from ever reaching _prepare_device_kwargs.
  • translations/de.json: Added selector.entry_type.options.none / .service German translations. Also added connections_invalid_format error key to config.error and options.error.
  • const.py: Added comment clarifying that CONF_ENTRY_TYPE, CONF_CONNECTIONS, and CONF_IDENTIFIERS use non-JSON-serializable native HA types (DeviceEntryType, set/frozenset of tuples) and require normalization before storage.

@EuleMitKeule

Copy link
Copy Markdown
Owner

@copilot fix the mypy issues

Copilot AI commented Mar 27, 2026

Copy link
Copy Markdown
Author

@copilot fix the mypy issues

Fixed in 801ed24. The mypy error was Returning Any from function declared to return "list[list[str]] | None" in _set_to_list at line 208. Changed return value to return list(value) so the return type is always list[list[str]] instead of Any.

@sonarqubecloud

Copy link
Copy Markdown

@EuleMitKeule EuleMitKeule merged commit 6f25013 into dev-2.x Mar 27, 2026
9 checks passed
@EuleMitKeule EuleMitKeule deleted the copilot/add-device-registry-attributes branch March 27, 2026 02:16
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