Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e0485b03 |
@@ -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