Catch the 'Nothing(SVC)' placeholder serial in both fallback sites (issue #83)

The ARTIK051_DONGLE_REF firmware family reports the literal string
"Nothing(SVC)" as serialNum on every unit -- non-empty, so the existing
`if not serial` checks in config_flow.py's _probe_and_validate and
coordinator.py's _run_discovery don't catch it. Two such appliances on
one install (a fridge and a freezer, each its own dongle) then collide:
config_flow gives both the same unique_id and rejects the second as
"already configured" (bug 2), and even once that's worked around,
device_serial feeds every entity's unique_id too, so the second
appliance's entities get silently dropped with "does not generate
unique IDs" log lines (bug 4).

Add _is_placeholder_serial (duplicated in both modules rather than
imported, to avoid pulling config_flow into the runtime coordinator's
import graph or vice versa for a two-line check) and treat it the same
as an empty serial: fall back to host/port.

Type detection (bug 1) and the door sensor's field-name gap (bug 3),
also reported in this issue, are already fixed via the
claude/device-support-issue-77-freezer branch, which hit the same
ARTIK051_DONGLE_REF family from a different report -- not duplicated
here.
This commit is contained in:
Marc Billow
2026-07-27 01:00:05 +00:00
parent aff7647460
commit 2c9fda3db1
4 changed files with 69 additions and 2 deletions
+13 -1
View File
@@ -197,6 +197,18 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
return _order_candidates(live)
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
"""
return serial.strip().lower().startswith('nothing')
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
import cbor2
@@ -261,7 +273,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', '')
)
if not serial:
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
one_ui_version = (
resources
+17 -1
View File
@@ -49,6 +49,22 @@ class _NoOpDescriptor:
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
"""
return serial.strip().lower().startswith('nothing')
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages one Samsung appliance: session, discovery, polling."""
@@ -312,7 +328,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._unbound_hrefs = unbound
serial = info.get('x.com.samsung.da.serialNum', '')
if not serial:
if not serial or _is_placeholder_serial(serial):
serial = self._entry.data[CONF_HOST]
self.device_serial = serial
+16
View File
@@ -508,3 +508,19 @@ async def test_options_flow_finish_preserves_existing_options(
assert result['type'] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
def test_is_placeholder_serial_catches_nothing_svc():
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty,
so it must be caught by name, not by the plain `if not serial` check."""
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('Nothing(SVC)') is True
assert _is_placeholder_serial('nothing(svc)') is True
assert _is_placeholder_serial(' Nothing(SVC) ') is True
def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
+23
View File
@@ -136,6 +136,29 @@ def test_run_discovery_detects_washer_via_model_fallback(
assert coordinator.device_type_name == 'washer'
def test_run_discovery_falls_back_to_host_for_placeholder_serial(
hass: HomeAssistant, mock_entry
) -> None:
"""Issue #83: the ARTIK051_DONGLE_REF firmware family reports the
literal string 'Nothing(SVC)' as serialNum on every unit. Left as-is,
two such units get the same device_serial (which feeds both the HA
device-registry identifier and every entity's unique_id), so the
second one's entities silently collide and get dropped. It must be
treated the same as an empty serial and fall back to the host."""
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum':
'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000',
'x.com.samsung.da.description': 'ARTIK_REF_17K',
'x.com.samsung.da.serialNum': 'Nothing(SVC)',
},
'/otninformation/vs/0': {},
}
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None: