Name devices by type alone, so entity_ids stay readable
Home Assistant mints an entity_id from the device name plus the entity name, never from the unique_id, so the OCF device UUID the entries are now keyed on (#381) was never at risk of reaching one. What did reach one is the model: `device_display_name` folded modelNum into the device name, and modelNum is a board string, so a fridge registered 46 entities along the lines of `sensor.samsung_refrigerator_artik051_dongle_ref_energy`. The name is now the appliance type alone -- `sensor.samsung_refrigerator_ energy`. The model is unchanged on DeviceInfo's own `model` field, where the device page shows it and nothing derives an entity_id from it. Two registry type names get an explicit display spelling, since they are SmartThings/Samsung strings rather than English: 'airconditioner' and 'ehs'. Subdevice devices (#177) had the same problem twice over, taking the sibling's own board string ('Samsung Air Conditioner Tp2X Fac Bora Rac 21K'), so they are numbered by position instead: the master is unit 1 and the first sibling is Unit 2. Upgrading renames devices, which is display-only -- an entity_id is assigned once, at first registration, so existing ids and the automations referencing them are untouched. Only newly discovered entities get the shorter form. Three tests read entity_ids out of the registry after a real entry setup, which is the only place the device name and the translated entity name are combined: one pins the readable form, one asserts no id carries the device key (or the literal 'None' HA appends when an entity has no name at all), and one asserts the board string stays off.
This commit is contained in:
@@ -85,7 +85,9 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
5. The flow sends a DTLS `ClientHello` to every port in the `49152-49160` range at once and keeps the one that answers -- a real DTLS server identifies itself in about one round trip, and the probe stops there, so nothing is left behind on the appliance. Only that port is then given a real certificate handshake: it fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, and reads the device's identity and `/device/0`. On success it creates the config entry, already knowing the appliance's serial, model, and type.
|
||||
6. Every subsequent device only asks for the host IP. The stored CA credentials are reused, and so is the leaf cert itself -- every appliance accepts the same one -- so adding a second appliance doesn't depend on Samsung's cloud being reachable at all. If a device rejects the reused cert (the UUID behind it does rotate), the flow mints a fresh one and retries by itself.
|
||||
|
||||
Entities appear under one HA device per appliance, named for the appliance's type and model. Rename freely: the device is keyed on the appliance's own OCF device ID, not its name. (Some Samsung models ship the same serial number on every unit of a model, so the serial can't tell two of them apart -- the OCF device ID can.)
|
||||
Entities appear under one HA device per appliance, named for the appliance's type alone -- `Samsung Refrigerator`, `Samsung Air Conditioner` -- since Home Assistant builds every entity_id on the device out of that name, giving you `sensor.samsung_refrigerator_energy`. The model is registered on the device itself rather than folded into the name, so a board string like `ARTIK051_DONGLE_REF` shows up on the device page instead of in 46 entity_ids. Rename freely: the device is keyed on the appliance's own OCF device ID, not its name. (Some Samsung models ship the same serial number on every unit of a model, so the serial can't tell two of them apart -- the OCF device ID can.)
|
||||
|
||||
Upgrading from a release that put the model in the name renames the device, but Home Assistant assigns an entity_id once, when the entity is first registered -- existing entity_ids, and every automation referencing them, are untouched. Rename the entities yourself if you want the shorter ids retroactively.
|
||||
|
||||
---
|
||||
|
||||
@@ -279,7 +281,7 @@ This only applies to an appliance the integration has reached at least once. A b
|
||||
|
||||
### Multi-subdevice ("2-in-1") air conditioner systems
|
||||
|
||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (named `... Unit 2`, `Unit 3`, and linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
|
||||
|
||||
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
|
||||
|
||||
|
||||
@@ -840,7 +840,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
from .registry.identity import device_display_name
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{device_display_name(info['device_type_name'], '')} ({self._host})",
|
||||
title=f"{device_display_name(info['device_type_name'])} ({self._host})",
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: info["port"],
|
||||
|
||||
@@ -324,9 +324,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.device_key)},
|
||||
name=device_display_name(
|
||||
entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
|
||||
),
|
||||
name=device_display_name(entry.data.get(CONF_DEVICE_TYPE)),
|
||||
manufacturer=entry.data.get(CONF_MANUFACTURER) or "Samsung",
|
||||
model=entry.data.get(CONF_MODEL) or None,
|
||||
)
|
||||
@@ -760,22 +758,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
model = model_num.split("|", 1)[0] if model_num else ""
|
||||
serial = info.get("x.com.samsung.da.serialNum") or None
|
||||
base_name = self.device_info.get("name") or "Samsung Appliance"
|
||||
if model:
|
||||
label = model.replace("_", " ").title()
|
||||
else:
|
||||
# No identity resource yet (or ever) for this subdevice -- fall
|
||||
# back to a generic label. 'Subdevice <n>' only applies to an
|
||||
# indexed subdevice; UUID-prefixed ones are never more than one
|
||||
# per connection today.
|
||||
label = (
|
||||
f"Subdevice {subdevice.key}"
|
||||
if subdevice.kind == "indexed"
|
||||
else "Secondary Subdevice"
|
||||
)
|
||||
# Positional, not this subdevice's own modelNum: that is a board
|
||||
# string ('TP2X_FAC_BORA_RAC_21K') and this name is what HA
|
||||
# slugifies into the subdevice's entity_ids, same rule as
|
||||
# device_display_name. The master is unit 1 -- `subdevices` never
|
||||
# includes it -- so the first sibling is unit 2.
|
||||
ordinal = self.subdevices.index(subdevice) + 2 if subdevice in self.subdevices else 2
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_key}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_key),
|
||||
name=f"{base_name} {label}",
|
||||
name=f"{base_name} Unit {ordinal}",
|
||||
manufacturer=self.device_info.get("manufacturer") or "Samsung",
|
||||
model=model or None,
|
||||
serial_number=serial,
|
||||
@@ -1452,7 +1444,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
ident = self._identity
|
||||
model = resolve_model(model_num, ident)
|
||||
name = device_display_name(device_type_name, model)
|
||||
name = device_display_name(device_type_name)
|
||||
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
|
||||
@@ -135,14 +135,37 @@ def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
return identity.model if identity else ""
|
||||
|
||||
|
||||
def device_display_name(device_type_name: str | None, model: str) -> str:
|
||||
"""The HA device name for a resolved device type + model. Shared by the
|
||||
config flow and the coordinator's post-discovery rebuild, so the name a
|
||||
device is first registered under matches what discovery produces later
|
||||
-- otherwise every setup would rename the device once the first poll
|
||||
landed."""
|
||||
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
|
||||
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
# Registry type names whose title-cased form isn't what the appliance is
|
||||
# called: 'airconditioner' is one word only because SmartThings' own type
|
||||
# string is, and 'ehs' is Samsung's internal name for the air-to-water
|
||||
# heat pump (see by_type/ehs.py).
|
||||
_DISPLAY_TYPE_NAMES = {
|
||||
"airconditioner": "Air Conditioner",
|
||||
"ehs": "Heat Pump",
|
||||
}
|
||||
|
||||
|
||||
def device_display_name(device_type_name: str | None) -> str:
|
||||
"""The HA device name for a resolved device type.
|
||||
|
||||
Deliberately carries no model: HA slugifies this name into the
|
||||
entity_id of every entity the device registers, and modelNum is a
|
||||
board string, so folding it in produced ids like
|
||||
`sensor.samsung_refrigerator_artik051_dongle_ref_energy`. The model
|
||||
still reaches the UI through DeviceInfo's own `model` field, which
|
||||
nothing derives an entity_id from.
|
||||
|
||||
Shared by the config flow and the coordinator's post-discovery
|
||||
rebuild, so the name a device is first registered under matches what
|
||||
discovery produces later -- otherwise every setup would rename the
|
||||
device once the first poll landed.
|
||||
"""
|
||||
if not device_type_name:
|
||||
return "Samsung Appliance"
|
||||
device_type = _DISPLAY_TYPE_NAMES.get(
|
||||
device_type_name, device_type_name.replace("_", " ").title()
|
||||
)
|
||||
return f"Samsung {device_type}"
|
||||
|
||||
|
||||
def _get(sess, path) -> dict:
|
||||
|
||||
@@ -216,7 +216,7 @@ def test_identity_is_resolved_before_any_poll(hass: HomeAssistant, mock_entry) -
|
||||
assert coordinator.device_key == MOCK_DEVICE_KEY
|
||||
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_DEVICE_KEY)}
|
||||
assert coordinator.device_info["model"] == MOCK_MODEL
|
||||
assert coordinator.device_info["name"] == f"Samsung Refrigerator ({MOCK_MODEL})"
|
||||
assert coordinator.device_info["name"] == "Samsung Refrigerator"
|
||||
assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""What HA actually names the entities this integration registers.
|
||||
|
||||
Every other naming test checks an input to that -- a translation key, a
|
||||
device name, a unique_id. This one checks the output: it sets an entry up
|
||||
for real and reads the entity_ids out of the registry, which is the only
|
||||
place the two halves (device name + translated entity name) are combined.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MOCK_DEVICE_KEY, MOCK_MODEL
|
||||
|
||||
|
||||
async def _entity_ids(hass: HomeAssistant, entry) -> list[str]:
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
registry = er.async_get(hass)
|
||||
return sorted(e.entity_id for e in er.async_entries_for_config_entry(registry, entry.entry_id))
|
||||
|
||||
|
||||
async def test_entity_ids_read_as_device_type_plus_entity(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
ids = await _entity_ids(hass, mock_entry)
|
||||
|
||||
assert "sensor.samsung_refrigerator_energy" in ids
|
||||
assert "binary_sensor.samsung_refrigerator_door_freezer_open" in ids
|
||||
|
||||
|
||||
async def test_no_entity_id_carries_the_device_key(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""The device key is a 36-character UUID since issue #381, and every
|
||||
entity's unique_id is minted from it -- but HA builds entity_ids from
|
||||
the device and entity names, so it stays internal. An entity that
|
||||
registers with no name is the one way it leaks: HA appends a literal
|
||||
'None' to the device name, and falls back to `<platform>_<unique_id>`
|
||||
outright for an entity with no device. test_translations stops that at
|
||||
the source by requiring a catalog entry per descriptor; this is the
|
||||
same check at the far end of the pipeline.
|
||||
"""
|
||||
ids = await _entity_ids(hass, mock_entry)
|
||||
|
||||
assert ids
|
||||
key_fragment = MOCK_DEVICE_KEY.split("-")[0]
|
||||
assert not [entity_id for entity_id in ids if key_fragment in entity_id]
|
||||
assert not [entity_id for entity_id in ids if "none" in entity_id.split(".")[1].split("_")]
|
||||
|
||||
|
||||
async def test_the_board_model_stays_off_the_entity_id(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""modelNum is a board string ('ARTIK051_DONGLE_REF'), so it belongs on
|
||||
the device's `model` field, not slugified into 46 entity_ids."""
|
||||
ids = await _entity_ids(hass, mock_entry)
|
||||
|
||||
slug = MOCK_MODEL.lower().replace("-", "_")
|
||||
assert not [entity_id for entity_id in ids if slug in entity_id]
|
||||
@@ -2,6 +2,7 @@ import cbor2
|
||||
|
||||
from custom_components.localthings.registry.identity import (
|
||||
DeviceIdentity,
|
||||
device_display_name,
|
||||
is_usable_device_id,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
@@ -252,3 +253,19 @@ def test_ocf_device_key_reports_absence_rather_than_collapsing_to_the_serial():
|
||||
assert ocf_device_key(None) is None
|
||||
assert ocf_device_key(_identity(serial="REAL-SERIAL")) is None
|
||||
assert ocf_device_key(_identity(device_id="abc-123")) == "abc-123"
|
||||
|
||||
|
||||
def test_the_device_name_is_the_device_type_alone():
|
||||
"""The name is what HA slugifies into every entity_id on the device, so
|
||||
the board string that modelNum reports stays out of it -- it is still
|
||||
registered as the device's `model` (see coordinator.device_info)."""
|
||||
assert device_display_name("refrigerator") == "Samsung Refrigerator"
|
||||
assert device_display_name("range_hood") == "Samsung Range Hood"
|
||||
assert device_display_name(None) == "Samsung Appliance"
|
||||
|
||||
|
||||
def test_registry_type_names_that_dont_title_case_into_english():
|
||||
"""Two registry names are SmartThings/Samsung spellings, not what the
|
||||
appliance is called -- and this one is the user-visible half."""
|
||||
assert device_display_name("airconditioner") == "Samsung Air Conditioner"
|
||||
assert device_display_name("ehs") == "Samsung Heat Pump"
|
||||
|
||||
@@ -151,8 +151,10 @@ async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeA
|
||||
assert info["identifiers"] == {(DOMAIN, f"{master_serial}_1")}
|
||||
assert info["via_device"] == (DOMAIN, master_serial)
|
||||
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
|
||||
# is what names/models this device, not the master's.
|
||||
# is what models this device, not the master's -- but it never reaches
|
||||
# the name, which HA slugifies into this subdevice's entity_ids.
|
||||
assert info["model"] == "ARTIK051_DONGLE_FAC_RAC_18K"
|
||||
assert info["name"] == "Samsung Air Conditioner Unit 2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -251,6 +253,9 @@ async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
|
||||
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
|
||||
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
|
||||
assert info["model"] == "TP2X_FAC_BORA_RAC_21K"
|
||||
# Neither the board string nor the subdevice UUID is a name a user
|
||||
# wants slugified into `climate.<this>_...`.
|
||||
assert info["name"] == "Samsung Air Conditioner Unit 2"
|
||||
|
||||
|
||||
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
|
||||
|
||||
Reference in New Issue
Block a user