Status: Accepted — implemented on feature/entity-naming (CR-260608-entity-naming). Maintainer-approved after independent review (which caught the v7-path consumer, now incorporated).
Date: 2026-06-08
Supersedes/relates: ENH-260608-entity-naming. Gold quality-scale entity-naming.
Numbering note: ADR-012 = config-entry-runtime-data (on dev). This ADR takes 013 (lands first). The local proto/fw-version-sot prototype, previously noted to renumber to ADR-013, should now use ADR-014.
Distinct entities collapse to identical friendly names → Home Assistant disambiguates with _2/_3/… entity_id suffixes. Two families are affected. Both root causes were verified live against the running v2.3.18 box (recorder DB, 2026-06-08), which corrected the prior brief's premise.
device_tracker (device_tracker_types.py:54, name="") and the client_traffic_* sensors (sensor_types.py:701+) both derive their name from host[uid]["host-name"] — client-traffic inherits it directly (coordinator.py:2736). In custom_name (entity.py:293-311) the tracker takes the empty-name branch (:311 → data["host-name"]); the traffic sensors take the compose branch (:309 → "{host-name} {name}").
Live evidence (REFUTES the brief's "named after the interface"):
device_tracker.lwip0 mac=AA:BB:CC:DD:EE:01 host_name="lwip0" interface="bridge" source=dhcp
device_tracker.lwip0_2 mac=AA:BB:CC:DD:EE:02 host_name="lwip0" interface="bridge" source=dhcp
… _3 AA:BB:CC:DD:EE:03, _4 AA:BB:CC:DD:EE:04, _5 AA:BB:CC:DD:EE:05, _6 AA:BB:CC:DD:EE:06
host-name == "lwip0" but interface == "bridge" — not equal. "lwip0" is the DHCP hostname these devices report (lwIP is the lightweight embedded TCP/IP stack; ESP8266/ESP32-class IoT devices default their hostname to lwip0). So this is a non-unique reported hostname, not interface-based naming. The coordinator already falls back to MAC (_hostname_from_dhcp → return uid, coordinator.py:2593) but only when host-name == "unknown" (:2548); a non-unique-but-present hostname slips through. The only stable distinguisher among the colliding hosts is the MAC (IP is dynamic; interface/source/host-name are identical).
dhcp_server_status / dhcp_server_lease_count (sensor_types.py:876/892) have data_name == data_reference == "name". The entity.py:306 shortcut (data[data_reference] == data[data_name]) is therefore always true → collapses to the static label, dropping the distinguishing server name.
Live evidence (CONFIRMS the brief):
…dhcp_server name="dhcp88" (bridge) friendly="… RB4011… DHCP server"
…dhcp_server_2 name="dhcp20" (vlan20) friendly="… RB4011… DHCP server" ← same
…_3 dhcp30, _4 dhcp40, _5 dhcp99 — five distinct VLAN servers, identical friendly name
All five attach to the single ha_group="System" device, so (unlike per-interface entities) there is no device-level disambiguation. The distinct name (dhcp88/20/30/40/99) is the distinguisher and it is being dropped.
Scope constraint (verified): ~20 descriptors share data_name == data_reference (ppp, poe_out_*, traffic, queue, interface, environment, kidcontrol, dhcp_client, …). They rely on the :306 shortcut and do not collide because each gets its own device. Any fix to line 306 must be scoped to dhcp_server only, or it renames dozens of entity types for every user.
At the end of async_process_host() (called at coordinator.py:676, before _async_update_client_traffic() at :691), after the host-resolution loop (:2696-2707), add a pass that:
- Counts
host-nameoccurrences acrossself.ds["host"]. - For each host whose
host-nameis shared by more than one host, set the display name to"{host-name} ({mac-address})"(maintainer's chosen format — keep the reported name, append the MAC to disambiguate). - Unique real hostnames are left unchanged. Empty/
unknownalready resolves to the MAC via the existing fallback — unchanged.
This fixes both client families with one change, at the source ("one owner per fact"). device_tracker.lwip0 → lwip0 (AA:BB:CC:DD:EE:01); its traffic sensors → lwip0 (AA:BB:CC:DD:EE:01) LAN TX, etc.
Insertion point is load-bearing (independent review catch).
client_trafficinheritshost-nameat two sites, gated by firmware in_async_update_client_traffic(:761-767): v<7 →process_accounting/_init_accounting_hosts(copy at:2736) and v≥7 →process_kid_control_devices(copy at:2911). The live v2.3.18/RouterOS-7 box uses the:2911path. The pass must therefore run at the end ofasync_process_host(before:691) so it precedes both copies — not insideprocess_accounting, which would silently miss every RouterOS-7 install.device_trackerreadshost[uid]directly, so it is fixed regardless; only the traffic sensors depend on this ordering.
Add a boolean to the entity description, e.g. data_name_compose: bool = False, designed generally (not dhcp-hardcoded): when True, custom_name skips the :306 equality shortcut and always composes → "{data[data_name]} {entity_description.name}". Set it only on dhcp_server_status and dhcp_server_lease_count. Result: "dhcp88 DHCP server" / "dhcp88 DHCP server leases". No other descriptor is touched.
Flag contract (must be explicit): data_name_compose is evaluated inside the if self.entity_description.name: branch and only overrides the :306 equality shortcut. The data_name_comment branch (entity.py:302-303) still takes precedence when set — the two are independent, and the dhcp_server descriptors carry no comment attribute, so the interaction is moot for the targets but is specified so a future consumer (see §Out of scope) isn't surprised.
Non-breaking — the "don't break existing users" guarantee (verified):
unique_idis unchanged in both families — MAC-based for clients (entity.py:316-317,data_reference="mac-address"), name-based for DHCP (data_reference="name"). HA fixesentity_idat registry creation fromunique_id; existing entity_ids and the automations that reference them are untouched.- Only the friendly name updates live, and new entities (created after upgrade) get the improved entity_id slug. Existing
_2/_3entity_ids persist until a user runs the cleanup service (out of scope here). - No
unique_idmigration; no config-entry migration.
Costs / risks:
- Friendly names visibly change for affected entities (intended).
- The duplicate-detection is per-poll over the current host set — O(n) over hosts, negligible.
- A device that starts sharing a hostname mid-life gets the MAC-suffixed name on the next poll (correct, but the displayed name changes); a device whose duplicate-peer leaves keeps its suffixed name until it's the sole holder again. Acceptable and self-correcting.
coordinator.py: new_disambiguate_duplicate_hostnames()called at the end ofasync_process_host()(before_async_update_client_trafficat:691); mutateshost[uid]["host-name"]to"{host-name} ({mac})"for shared names only. Runs after_resolve_hostname, so it sees the resolved value; raw host-name is re-read from the API each poll, so it re-derives cleanly. Both client_traffic copy sites then inherit it:_init_accounting_hosts(:2736, v<7) andprocess_kid_control_devices(:2911, v≥7).sensor_types.py(and any other*EntityDescriptiondataclass that needs it): adddata_name_compose: bool = False; setTrueon the twodhcp_server_*descriptors.entity.pycustom_name: honourdata_name_compose(compose, bypassing the:306equality) — guarded so all other descriptors are unaffected;data_name_commentstill wins per the flag contract above.
- Clients: given a host set with two hosts sharing
host-name="lwip0"and distinct MACs, assert each renders"lwip0 (<mac>)"and the two names are distinct; a host with a unique hostname renders unchanged. - Empty/unknown hosts: two hosts that both fall back to MAC (no DNS/DHCP name) render their distinct MACs and are not suffixed (they aren't "shared" — distinct MAC values → distinct names).
- Client-traffic — BOTH firmware paths: assert the disambiguated name flows into
client_traffic_*via_init_accounting_hosts(v<7) and viaprocess_kid_control_devices(v≥7 — the live box's path). A v7-path test is mandatory, not optional. - DHCP: two
dhcp_serverrows with distinctnamerender distinct"{name} DHCP server"; assert an entity withoutdata_name_compose(e.g.queue,poe_out_status,ppp_secret) is unchanged (scope guard). - unique_id invariance: assert
unique_idis identical before/after for all the above — explicitly including the client path (the dedup mutateshost-name, butunique_idkeys onmac-address, so it must stay stable).
Verify on the WSL/devbox runner under python:3.13 and python:3.14.
jnctech #70 ("use netwatch names as device names") is the same class of bug — many netwatch entities collapse to one name because they share comment, and the user wants the distinct name field shown. It is deliberately out of scope here because it needs more than this ADR's flag:
get_netwatch(coordinator.py:~1542) does not currently parse anamefield — it would have to be added to the dataset.- netwatch's descriptor uses
data_name_comment=True, so the user's "use name, not comment" requires a precedence decision (name vs comment) that conflicts with the current comment-first behaviour. - The collapse there fires via the comment branch (
entity.py:302-303), not only the:306shortcut.
data_name_compose is therefore designed generally (§B) so that, in a follow-up PR, netwatch can adopt it (with data_name="name") once get_netwatch provides name and the precedence is decided. Tracked separately; not delivered by this ADR.
- upstream tomaae #130 (device-tracker id churn after re-add) — about
unique_idstability, which this ADR leaves untouched. No regression. - upstream #306 (duplicate names in the device, e.g. "hAP ac³ hAP ac³") — device-registry layer, not
custom_name. Out of scope, not claimed. - upstream #321 (expose DHCP client/relay/server values) — new sensors, orthogonal to naming. Not delivered here.
All facts cite code file:line on dev or live recorder-DB queries against the running v2.3.18 box (2026-06-08). The brief's "named after the interface" premise and the host-name == interface detector were both refuted by the live data and replaced with the non-unique-hostname model above.