From 2c9fda3db15f4923d3e25d618e30443cb9ff280a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 01:00:05 +0000 Subject: [PATCH] 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. --- custom_components/localthings/config_flow.py | 14 +++++++++++- custom_components/localthings/coordinator.py | 18 ++++++++++++++- tests/localthings/test_config_flow.py | 16 ++++++++++++++ tests/localthings/test_coordinator.py | 23 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 664f170..37a8595 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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 diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 171630f..643b822 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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 diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 0f4fee5..20f4270 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -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 diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 0a1b420..bc5f343 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -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: