Fix ARTIK051_DONGLE_REF freezer type detection and door sensor (issue #77)
RZ32M713EWW/EE (an ARTIK051-dongle standalone freezer) reports no
oneUiVersion and a pipe-delimited modelNum
('ARTIK051_DONGLE_REF|<rest>') -- REF is the last underscore segment
before the pipe, not wrapped in underscores on both sides like the
'..._REF_...' shape for_device_by_model's substring check expected, so
the device fell through to 'unknown' with only common capabilities:
no door sensor, no temperature sensors, nothing fridge-specific.
Replace the substring check with a segment-based one
(_model_num_segments splits the pipe-delimited prefix on '_') that
catches both shapes. Same root cause, same fix, and same device family
independently reported and root-caused in issue #83 (which also covers
two further bugs -- config-flow/coordinator serial collisions on a
second symptom of this firmware, 'Nothing(SVC)' as a literal serial --
not needed here since this reporter has a single unit; left for #83).
Once routed to the refrigerator registry, fridge.py's existing pattern
capabilities pick up /temperature/current/freezer/0 and
/temperature/desired/freezer/0 for free -- they were never the actual
problem, just unreachable under the 'unknown' fallback (which never
tries pattern capabilities at all). The door sensor needed one more
fix: /door/onedoorfreezer/vs/0 reports the vendor-prefixed
x.com.samsung.da.openState, not the bare openState DOOR_GENERIC read,
so the entity existed but stayed permanently unavailable. Check both
field names via rep_fn.
This commit is contained in:
@@ -87,6 +87,22 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _model_num_segments(model_num: str) -> list[str]:
|
||||
"""Underscore-delimited segments of modelNum's pipe-prefix.
|
||||
|
||||
Most boards wrap a token in underscores on both sides ('..._REF_...'),
|
||||
which a plain substring check catches fine. But the ARTIK051_DONGLE_REF
|
||||
family (issues #77, #83) reports modelNum as
|
||||
'<board>_DONGLE_REF|<rest...>' -- REF is the *last* segment before the
|
||||
pipe, with no trailing underscore, so '_REF_' never matches and the
|
||||
device silently fell back to 'unknown'. Splitting on '_' and checking
|
||||
segment membership catches both shapes without caring which side (if
|
||||
either) has a delimiter.
|
||||
"""
|
||||
prefix = (model_num or '').split('|', 1)[0]
|
||||
return prefix.split('_')
|
||||
|
||||
|
||||
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
|
||||
"""Fallback device-type detection for hardware that never reports
|
||||
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
|
||||
@@ -102,7 +118,7 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
"""
|
||||
token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1]
|
||||
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
|
||||
if key is None and '_REF_' in (model_num or ''):
|
||||
if key is None and 'REF' in _model_num_segments(model_num):
|
||||
key = 'refrigerator'
|
||||
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
|
||||
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
|
||||
|
||||
@@ -573,15 +573,27 @@ FLEX_ZONE = Capability(
|
||||
# Generic door pattern capability (href=None — use as pattern_cap only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _door_open_state(rep):
|
||||
"""Most /door/* resources report bare `openState`, but the
|
||||
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83)
|
||||
reports the vendor-prefixed `x.com.samsung.da.openState` instead. This
|
||||
capability still binds either way (href_prefix match doesn't care about
|
||||
field names), but a plain `field=` lookup against the wrong key means
|
||||
the entity exists and is permanently unavailable -- check both."""
|
||||
v = rep.get('openState')
|
||||
if v is None:
|
||||
v = rep.get('x.com.samsung.da.openState')
|
||||
return v == 'Open'
|
||||
|
||||
|
||||
DOOR_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/door/',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='open', field='openState',
|
||||
BinarySensorDesc(key='open', rep_fn=_door_open_state,
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door',
|
||||
value_fn=lambda v: v == 'Open'),
|
||||
use_instance_name=True, device_class='door'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"defrost_active",
|
||||
"defrost_delay",
|
||||
"diagnosis_status",
|
||||
"door_onedoorfreezer_open",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"ice_maker_enabled",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/delay/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/delay/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/onedoorfreezer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK_REF_17K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "011120190711",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2103120d,ffffffff",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/0",
|
||||
"rep": {
|
||||
"defrost": false,
|
||||
"rapidFreeze": false,
|
||||
"rapidCool": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFreezing": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sabbath/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-20",
|
||||
"x.com.samsung.da.current": "-20",
|
||||
"x.com.samsung.da.maximum": "-15",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -111,6 +111,25 @@ class TestWasherRegistry:
|
||||
assert href in registry.capabilities, f"{href} missing from washer registry"
|
||||
|
||||
|
||||
class TestModelNumSegments:
|
||||
def test_splits_pipe_prefix_on_underscore(self):
|
||||
from custom_components.localthings.registry.by_type import _model_num_segments
|
||||
assert _model_num_segments('ARTIK051_DONGLE_REF|00127641|000800200014') == [
|
||||
'ARTIK051', 'DONGLE', 'REF',
|
||||
]
|
||||
|
||||
def test_ignores_everything_after_first_pipe(self):
|
||||
from custom_components.localthings.registry.by_type import _model_num_segments
|
||||
assert _model_num_segments('TP2X_RAC_20K|abc|REF_should_not_appear') == [
|
||||
'TP2X', 'RAC', '20K',
|
||||
]
|
||||
|
||||
def test_empty_for_none_or_empty_input(self):
|
||||
from custom_components.localthings.registry.by_type import _model_num_segments
|
||||
assert _model_num_segments('') == ['']
|
||||
assert _model_num_segments(None) == ['']
|
||||
|
||||
|
||||
class TestForDeviceByModel:
|
||||
"""Fallback device-type detection for hardware without oneUiVersion."""
|
||||
|
||||
@@ -178,6 +197,20 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_refrigerator_dongle_ref_pipe_delimited_modelnum(self):
|
||||
"""Issues #77/#83: the ARTIK051_DONGLE_REF family's modelNum is
|
||||
'<board>_DONGLE_REF|<rest>' -- REF is the last underscore segment
|
||||
before the pipe, with no trailing underscore, so the plain '_REF_'
|
||||
substring check used to miss it entirely and the device fell back
|
||||
to 'unknown' with only common capabilities."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000',
|
||||
'ARTIK_REF_17K',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_airconditioner_via_prac_token(self):
|
||||
"""Issue #17: a room AC (ARTIK051_PRAC_20K) reports no oneUiVersion and
|
||||
an unrecognized consumer token ('20K'); it falls back to the '_PRAC_'
|
||||
|
||||
@@ -20,6 +20,29 @@ class TestTempCurrentGeneric:
|
||||
assert desc.unit_fn({'temperature': 5.0}) == '°F'
|
||||
|
||||
|
||||
class TestDoorGeneric:
|
||||
"""Issues #77/#83: DOOR_GENERIC used to read only the bare `openState`
|
||||
field. Most /door/* resources use that, but the ARTIK051_DONGLE_REF
|
||||
family's /door/onedoorfreezer/vs/0 reports the vendor-prefixed
|
||||
`x.com.samsung.da.openState` instead -- the pattern capability still
|
||||
bound the href either way, but the entity's value was always None."""
|
||||
|
||||
def test_reads_bare_open_state(self):
|
||||
desc = fridge.DOOR_GENERIC.entities[0]
|
||||
assert desc.rep_fn({'openState': 'Open'}) is True
|
||||
assert desc.rep_fn({'openState': 'Close'}) is False
|
||||
|
||||
def test_reads_vendor_prefixed_open_state(self):
|
||||
desc = fridge.DOOR_GENERIC.entities[0]
|
||||
assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True
|
||||
assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False
|
||||
|
||||
def test_prefers_bare_field_when_both_present(self):
|
||||
desc = fridge.DOOR_GENERIC.entities[0]
|
||||
rep = {'openState': 'Open', 'x.com.samsung.da.openState': 'Close'}
|
||||
assert desc.rep_fn(rep) is True
|
||||
|
||||
|
||||
class TestTempSetpointGeneric:
|
||||
def test_unit_reads_celsius(self):
|
||||
desc = fridge.TEMP_SETPOINT_GENERIC.entities[0]
|
||||
@@ -231,6 +254,39 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
||||
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
|
||||
|
||||
|
||||
class TestArtik051DongleRefFixtureCoverage:
|
||||
"""Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves
|
||||
to the refrigerator registry (segment-based modelNum detection) with
|
||||
zero unbound hrefs, and its door/temperature entities -- which only
|
||||
bind through pattern capabilities the 'unknown' fallback never tried --
|
||||
now carry real values."""
|
||||
|
||||
def test_no_unbound_hrefs_and_expected_entities(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device_by_model, refrigerator,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_artik051_dongle_ref')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
assert reg is not None and reg.name == 'refrigerator'
|
||||
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources, refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities, log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['door_onedoorfreezer_open'] is False # rep reports 'Close'
|
||||
assert state['freezer_temperature'] == -20.0
|
||||
assert state['freezer_setpoint'] == -20.0
|
||||
|
||||
|
||||
class TestRefrigeratorAiEnergyLevelFixtureCoverage:
|
||||
"""AI energy-saving level (common.AI_ENERGY_LEVEL, see
|
||||
test_common_capabilities.py) is exercised end-to-end here against the
|
||||
|
||||
@@ -166,6 +166,27 @@ def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_artik051_dongle_ref():
|
||||
"""ARTIK051_DONGLE_REF standalone freezer (issues #77/#83) -- reports no
|
||||
oneUiVersion and a pipe-delimited modelNum ('..._DONGLE_REF|<rest>')
|
||||
that the old '_REF_' substring check missed entirely; resolved via the
|
||||
segment-based check in for_device_by_model. Its door
|
||||
(/door/onedoorfreezer/vs/0) and temperature
|
||||
(/temperature/{current,desired}/freezer/0) hrefs only bind through
|
||||
fridge.py's pattern capabilities, which the 'unknown' fallback never
|
||||
tries -- so this also regression-tests that those resources bind at
|
||||
all once routed to the right registry."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_artik051_dongle_ref')
|
||||
golden = json.loads((GOLDEN / 'refrigerator_artik051_dongle_ref.json').read_text())
|
||||
state_keys = _new_state_keys('refrigerator_artik051_dongle_ref', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
|
||||
"""TP2X_REF_20K -- CV_FDR_-prefixed flex zone (issue #32) plus the extra
|
||||
energy fields (cumulativeConsumption/monthlyConsumption/
|
||||
|
||||
Reference in New Issue
Block a user