Compare commits

...
Author SHA1 Message Date
Marc Billow f2e0485b03 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.
2026-08-18 03:29:35 +00:00
8 changed files with 130 additions and 30 deletions
+4 -2
View File
@@ -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. 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. 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 ### 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: Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
+1 -1
View File
@@ -840,7 +840,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
from .registry.identity import device_display_name from .registry.identity import device_display_name
return self.async_create_entry( 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={ data={
CONF_HOST: self._host, CONF_HOST: self._host,
CONF_PORT: info["port"], CONF_PORT: info["port"],
+9 -17
View File
@@ -324,9 +324,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
identifiers={(DOMAIN, self.device_key)}, identifiers={(DOMAIN, self.device_key)},
name=device_display_name( name=device_display_name(entry.data.get(CONF_DEVICE_TYPE)),
entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
),
manufacturer=entry.data.get(CONF_MANUFACTURER) or "Samsung", manufacturer=entry.data.get(CONF_MANUFACTURER) or "Samsung",
model=entry.data.get(CONF_MODEL) or None, 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 "" model = model_num.split("|", 1)[0] if model_num else ""
serial = info.get("x.com.samsung.da.serialNum") or None serial = info.get("x.com.samsung.da.serialNum") or None
base_name = self.device_info.get("name") or "Samsung Appliance" base_name = self.device_info.get("name") or "Samsung Appliance"
if model: # Positional, not this subdevice's own modelNum: that is a board
label = model.replace("_", " ").title() # string ('TP2X_FAC_BORA_RAC_21K') and this name is what HA
else: # slugifies into the subdevice's entity_ids, same rule as
# No identity resource yet (or ever) for this subdevice -- fall # device_display_name. The master is unit 1 -- `subdevices` never
# back to a generic label. 'Subdevice <n>' only applies to an # includes it -- so the first sibling is unit 2.
# indexed subdevice; UUID-prefixed ones are never more than one ordinal = self.subdevices.index(subdevice) + 2 if subdevice in self.subdevices else 2
# per connection today.
label = (
f"Subdevice {subdevice.key}"
if subdevice.kind == "indexed"
else "Secondary Subdevice"
)
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, f"{self.device_key}_{subdevice.key}")}, identifiers={(DOMAIN, f"{self.device_key}_{subdevice.key}")},
via_device=(DOMAIN, self.device_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", manufacturer=self.device_info.get("manufacturer") or "Samsung",
model=model or None, model=model or None,
serial_number=serial, serial_number=serial,
@@ -1452,7 +1444,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
ident = self._identity ident = self._identity
model = resolve_model(model_num, ident) 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" mfr = (ident.manufacturer if ident else "") or "Samsung"
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
@@ -135,14 +135,37 @@ def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
return identity.model if identity else "" return identity.model if identity else ""
def device_display_name(device_type_name: str | None, model: str) -> str: # Registry type names whose title-cased form isn't what the appliance is
"""The HA device name for a resolved device type + model. Shared by the # called: 'airconditioner' is one word only because SmartThings' own type
config flow and the coordinator's post-discovery rebuild, so the name a # string is, and 'ehs' is Samsung's internal name for the air-to-water
device is first registered under matches what discovery produces later # heat pump (see by_type/ehs.py).
-- otherwise every setup would rename the device once the first poll _DISPLAY_TYPE_NAMES = {
landed.""" "airconditioner": "Air Conditioner",
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance" "ehs": "Heat Pump",
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" }
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: def _get(sess, path) -> dict:
+1 -1
View File
@@ -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_key == MOCK_DEVICE_KEY
assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_DEVICE_KEY)} assert coordinator.device_info["identifiers"] == {(DOMAIN, MOCK_DEVICE_KEY)}
assert coordinator.device_info["model"] == MOCK_MODEL 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"]) assert mock_entry.data[CONF_HOST] not in str(coordinator.device_info["identifiers"])
+61
View File
@@ -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]
+17
View File
@@ -2,6 +2,7 @@ import cbor2
from custom_components.localthings.registry.identity import ( from custom_components.localthings.registry.identity import (
DeviceIdentity, DeviceIdentity,
device_display_name,
is_usable_device_id, is_usable_device_id,
ocf_device_key, ocf_device_key,
read_identity, 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(None) is None
assert ocf_device_key(_identity(serial="REAL-SERIAL")) is None assert ocf_device_key(_identity(serial="REAL-SERIAL")) is None
assert ocf_device_key(_identity(device_id="abc-123")) == "abc-123" 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"
+6 -1
View File
@@ -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["identifiers"] == {(DOMAIN, f"{master_serial}_1")}
assert info["via_device"] == (DOMAIN, master_serial) assert info["via_device"] == (DOMAIN, master_serial)
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K) # 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["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 # 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. # subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
assert info["model"] == "TP2X_FAC_BORA_RAC_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): async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):