diff --git a/README.md b/README.md index ea6ebd9..07bc337 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index e561503..ad381e5 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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"], diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index a7da859..c06ad85 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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 ' 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( diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index 94181a3..0224b2a 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -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: diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index ed1844e..e33f94b 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -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"]) diff --git a/tests/localthings/test_entity_ids.py b/tests/localthings/test_entity_ids.py new file mode 100644 index 0000000..05d3092 --- /dev/null +++ b/tests/localthings/test_entity_ids.py @@ -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 `_` + 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] diff --git a/tests/test_identity.py b/tests/test_identity.py index f88f837..3ae52f0 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -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" diff --git a/tests/test_subdevice_discovery.py b/tests/test_subdevice_discovery.py index 01b320c..e94b279 100644 --- a/tests/test_subdevice_discovery.py +++ b/tests/test_subdevice_discovery.py @@ -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._...`. + assert info["name"] == "Samsung Air Conditioner Unit 2" async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):