Squash-merge PR #251: fix appliance course labels and AC setup timeouts

- Add confirmed Samsung Table_02 washer course mappings (69-79, 88)
- Add confirmed dishwasher course mappings (82, 8a, a7, a8, 8c, 88)
- Localize new washer/dishwasher course labels in en, cs, nl
- Decode device-provided personal washer course names from TLV payloads
- Show unrecognized washer enum bytes as 'Unknown (0xNN)'
- Normalize select current-state and options through one display path
- Bound first-setup subdevice enumeration with a shared time budget

Co-authored-by: galaxysj <224385302+galaxysj@users.noreply.github.com>
This commit is contained in:
Marc Billowandgalaxysj committed 2026-08-08 21:17:43 +00:00
1 parent 55765fdf9a
commit 00db7890f5
19 files changed
+771 -49

No files matched your search

@@ -202,6 +202,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_POST_TIMEOUT_S: float = 8.0
_POLL_TIMEOUT_S: float = 35.0
# First-discovery subdevice enumeration is part of config-entry setup, so
# it must have a finite wall-clock cost. A UUID-prefixed AC whose
# /<uuid>/device/0 Collection is absent falls back to individual property
# probes; some firmware silently drops unknown prefixed paths instead of
# returning 4.04, making the old 10s-per-href scan take several minutes.
# Keep enough time for a real blockwise Collection response, then use
# short timeouts for the small Property resources, all under one budget.
_SUBDEVICE_ENUMERATION_BUDGET_S: float = 15.0
_SUBDEVICE_COLLECTION_TIMEOUT_S: float = 10.0
_SUBDEVICE_PROPERTY_TIMEOUT_S: float = 1.0
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
# Per-device logger so every log line (including the base
# coordinator's and ObserveManager's) identifies which device it's
@@ -646,6 +657,45 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Discovery (runs once on first successful poll)
# ------------------------------------------------------------------
def _subdevice_probe_priority(
self, resources: dict[str, dict],
) -> tuple[str, ...]:
"""Return live primary-entity hrefs in hot/warm-first order.
A prefixed subdevice without a Collection endpoint has to be probed
one Property href at a time. Resolve the master through the same
registry used by discovery and put resources that produce primary
entities first. This is metadata-driven rather than an AC-specific
list: a future composite appliance gets the priority its own registry
declares, while unknown devices simply retain the normal href order.
"""
registry = resolve_registry(
resources,
device_types=self._identity.device_types if self._identity else (),
)
if registry is None:
return ()
tier_rank = {'hot': 0, 'warm': 1, 'cold': 2}
ranked = []
for order, href in enumerate(resources):
primary_caps = [
capability
for capability in registry.capabilities.get(href, ())
if any(
desc.entity_category is None
for desc in capability.entities
)
]
if not primary_caps:
continue
rank = min(
tier_rank.get(capability.poll_tier, 2)
for capability in primary_caps
)
ranked.append((rank, order, href))
return tuple(href for _, _, href in sorted(ranked))
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor
subdevices on this connection (issue #177) -- see
@@ -671,6 +721,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
resources,
oic_res,
probe_log=lambda href, found: probes.__setitem__(href, found),
preferred_hrefs=self._subdevice_probe_priority(resources),
time_budget=self._SUBDEVICE_ENUMERATION_BUDGET_S,
collection_timeout=self._SUBDEVICE_COLLECTION_TIMEOUT_S,
property_timeout=self._SUBDEVICE_PROPERTY_TIMEOUT_S,
)
self.subdevices = subdevices
self._subdevice_probes = probes
@@ -22,6 +22,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
from datetime import UTC, datetime
from datetime import time as dt_time
import string
from ...catalog import has_entity_translation
from ..capability import Capability
@@ -323,12 +324,56 @@ def cycle_write(p, rep, href=None):
}
def personal_course_labels(resources, href='/wm/personalcourse/vs/0'):
"""Return device-provided personal course names keyed by course code.
Populated entries use a small TLV payload. The leading field is
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
settings and are intentionally left uninterpreted. Empty slots are
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
opaque device data can never become a misleading label.
"""
rep = resources.get(href) or {}
labels = {}
for entry in rep.get('x.com.samsung.da.courses') or []:
if not isinstance(entry, str) or '_' not in entry:
continue
code, encoded = entry.split('_', 1)
try:
payload = bytes.fromhex(encoded)
except ValueError:
continue
if len(payload) < 3 or payload[0] != 0x01:
continue
name_length = payload[1]
if name_length == 0 or len(payload) < 2 + name_length:
continue
try:
name = payload[2:2 + name_length].decode('utf-8')
except UnicodeDecodeError:
continue
if name.strip() and name.isprintable():
labels[code.upper()] = name
return labels
def washer_cycle_fallback(value, resources):
"""Label an untranslated washer course without guessing its meaning."""
if not isinstance(value, str):
return None
if label := personal_course_labels(resources).get(value.upper()):
return label
if len(value) == 2 and all(char in string.hexdigits for char in value):
return f'Unknown (0x{value.upper()})'
return None
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get("x.com.samsung.da.st.courseTable")
def cycle_select(*, translation_key, icon, table_href=None):
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The option list, current value, and write path are shared across
@@ -346,6 +391,11 @@ def cycle_select(*, translation_key, icon, table_href=None):
instead of borrowing a label from another board generation --
translating a new table is a translations-only change.
The raw course code remains writable regardless; its display uses
display_fn when supplied, otherwise it remains raw. display_fn is an
optional family-specific fallback for untranslated raw values --
select.py applies it after catalog lookup to both state and options.
Left at its default for dishwasher, which has no equivalent table-id
resource and no evidence its codes vary by table the way washer/
dryer's do.
@@ -367,6 +417,7 @@ def cycle_select(*, translation_key, icon, table_href=None):
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
display_fn=display_fn,
write_fn=cycle_write,
)
@@ -27,6 +27,7 @@ from .laundry import (
hex_pairs,
option_value,
option_write,
washer_cycle_fallback,
)
# Course_XX hex code labels (translations/en.json,
@@ -50,6 +51,13 @@ from .laundry import (
# options' MostUsed_* entry was considered as a fallback source (its first
# byte matches the selected Course_XX on both dumps), but the remaining
# bytes don't decode to any confirmed course code, so it isn't used.
#
# The owner of a Korean Table_02 washer confirmed the names for its newer
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
# live only in the table-scoped translation catalog; codes not confirmed by
# the owner or device metadata continue to use the safe unknown-code fallback,
# via washer_cycle_fallback (personal-course names, then 'Unknown (0xNN)').
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
@@ -250,6 +258,7 @@ WASHER_COURSE = Capability(
translation_key="washer_cycle",
icon="mdi:washing-machine",
table_href="/st/washercourse/vs/0",
display_fn=washer_cycle_fallback,
),
SensorDesc(
key="drum_clean_cycles_remaining",
@@ -19,6 +19,7 @@ WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# option list).
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
DisplayFn = Callable[[Any, dict], Any] | None
def _identity(v: Any) -> Any:
@@ -77,6 +78,10 @@ class SelectDesc(SamsungEntityDescription):
# snapshot (not just this entity's own href) and returns raw device
# option values; see select.py's LocalThingsSelect._raw_options().
options_field: str | None = None # resource field that contains the live options list
# Optional device-specific fallback for values absent from the translation
# catalog. Receives (raw_value, canonical_resources); select.py applies it
# identically to the current state and every option.
display_fn: DisplayFn = None
write_fn: WriteFn = None
@@ -37,6 +37,7 @@ appliance's own bookkeeping, not evidence of a second indoor unit -- see
from __future__ import annotations
import re
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
@@ -221,12 +222,12 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
return "/" + "/".join(path_segs)
def _get_raw(sess, path_segs: tuple[str, ...]):
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
"""GET `path_segs` and CBOR-decode the payload, or None on any
missing/malformed response (a 4.04, a timeout, an empty payload) --
shared tolerated-absence posture for both callers below."""
try:
code, pl = sess.get(list(path_segs), timeout=10.0)
code, pl = sess.get(list(path_segs), timeout=timeout)
if code == 0x45 and pl:
return cbor2.loads(pl)
except Exception:
@@ -234,19 +235,25 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
return None
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
def _get_batch(
sess, path_segs: tuple[str, ...], timeout: float = 10.0,
) -> dict[str, dict]:
"""GET a Samsung Collection resource and parse it the same way
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
{href, rep}, ...] CBOR list, not a bare Property map."""
body = _get_raw(sess, path_segs)
body = _get_raw(sess, path_segs, timeout)
return parse_device0_batch(body) if isinstance(body, list) else {}
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
def _get_property(
sess, path_segs: tuple[str, ...], timeout: float = 10.0,
) -> dict:
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent
from `/device/0`'s batch, so it needs its own RETRIEVE."""
body = _get_raw(sess, path_segs)
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
`/oic/res` on the Pattern A reporter's board but absent from
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
single Property map, not a [devcol-rep, ...] list."""
body = _get_raw(sess, path_segs, timeout)
return body if isinstance(body, dict) else {}
@@ -255,6 +262,11 @@ def enumerate_subdevices(
resources: dict[str, dict],
oic_res_links,
probe_log: Callable[[str, bool], None] | None = None,
*,
preferred_hrefs: Sequence[str] = (),
time_budget: float | None = None,
collection_timeout: float = 10.0,
property_timeout: float = 10.0,
) -> tuple[list[Subdevice], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
@@ -269,6 +281,13 @@ def enumerate_subdevices(
or not it answered, so diagnostics can tell "checked, nothing there"
apart from "never checked".
`preferred_hrefs` only changes the order of the flat Property fallback;
it never filters the device's resource surface. When `time_budget` is
supplied, probes are bounded by one shared monotonic deadline and this
returns every candidate/resource confirmed before it. This makes first
setup finite even when firmware silently drops unknown paths instead of
returning 4.04.
Every candidate whose seed answers with a non-empty batch is returned
here -- this function can't tell a real sibling from an unused
SmartThings slot that answers the same shape; that requires
@@ -282,6 +301,33 @@ def enumerate_subdevices(
# casing, and probing it twice would materialize the same physical
# subdevice as two Subdevice candidates.
probed_ids: set[str] = set()
deadline = (
time.monotonic() + max(0.0, time_budget)
if time_budget is not None else None
)
budget_exhausted = False
def _next_timeout(maximum: float) -> float | None:
"""Clamp one probe to the remaining enumeration wall-clock budget."""
nonlocal budget_exhausted
if deadline is None:
return maximum
remaining = deadline - time.monotonic()
if remaining <= 0:
budget_exhausted = True
return None
return min(maximum, remaining)
def _flat_probe_hrefs():
"""Preferred live-state hrefs first, then every remaining master href."""
seen = set()
for href in preferred_hrefs:
if href in resources and href not in seen:
seen.add(href)
yield href
for href in sorted(resources):
if href not in seen:
yield href
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
@@ -296,7 +342,10 @@ def enumerate_subdevices(
return
probed_ids.add(sub_id.lower())
seed = (sub_id, "device", "0")
batch = _get_batch(sess, seed)
timeout = _next_timeout(collection_timeout)
if timeout is None:
return
batch = _get_batch(sess, seed, timeout)
_probed(_seed_href(seed), batch)
if batch:
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
@@ -307,10 +356,12 @@ def enumerate_subdevices(
# doesn't always expose its own `/<uuid>/device/0` Collection. With
# no Collection to seed from and no per-UUID entry in /oic/res to
# enumerate hrefs from, the only signal left is that a composite
# device's siblings share the master's own resource surface -- so
# probe every href the master answered this cycle, individually,
# under this UUID's prefix, and keep whichever answer. Each is a
# plain tolerated-404 RETRIEVE.
# device's siblings are the same physical board family as the
# subdevice this config entry already talks to -- so probe every
# href the master itself answered this cycle, individually, under
# this UUID's prefix, and keep whichever ones answer before the
# optional enumeration deadline. Each is a plain tolerated-404
# RETRIEVE, same posture as every other probe in this function.
#
# Known gap: a firmware that echoes the master's own state back
# under an unrecognized prefix, rather than 4.04ing, would pass
@@ -321,12 +372,15 @@ def enumerate_subdevices(
# reps against the master's own values for the same hrefs.
flat_hrefs = []
first = True
for href in sorted(resources):
for href in _flat_probe_hrefs():
if not first:
sess.pace()
first = False
timeout = _next_timeout(property_timeout)
if timeout is None:
break
actual = f"/{sub_id}{href}"
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
rep = _get_property(sess, tuple(actual.strip("/").split("/")), timeout)
_probed(actual, rep)
if rep:
flat_hrefs.append(href)
@@ -353,6 +407,8 @@ def enumerate_subdevices(
listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed:
_probe_prefixed(sub_id)
if budget_exhausted:
break
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
@@ -374,6 +430,8 @@ def enumerate_subdevices(
)
for sub_id in linked:
_probe_prefixed(sub_id)
if budget_exhausted:
break
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted(
@@ -390,8 +448,11 @@ def enumerate_subdevices(
# this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices:
timeout = _next_timeout(collection_timeout)
if timeout is None:
break
seed = ("device", str(n))
batch = _get_batch(sess, seed)
batch = _get_batch(sess, seed, timeout)
_probed(_seed_href(seed), batch)
if not batch:
continue
@@ -399,18 +460,26 @@ def enumerate_subdevices(
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
subdevices.append(subdevice)
# /multidevice/vs/0: listed in /oic/res on some boards but never in
# /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
# count (numofsubdevice), confirmed read-only -- captured for
# diagnostics only, folded into the merged resources dict like any
# other href (see airconditioner._AC_IGNORED). Not a gate:
# discover_partitioned's entity-level liveness check decides
# materialization without it.
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
# board lists it in /oic/res but it never appears in /device/0's batch,
# so it needs its own RETRIEVE. It's a plain corroborating count
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
# into the merged resources dict like any other href (see
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
# liveness check decides materialization correctly without it, and only
# this one board family is known to expose it at all. Whether it agrees
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ("multidevice", "vs", "0")
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched["/multidevice/vs/0"] = multidevice
timeout = _next_timeout(property_timeout)
if timeout is not None:
multidevice = _get_property(sess, multidevice_seed, timeout)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched["/multidevice/vs/0"] = multidevice
return subdevices, fetched
+26 -9
View File
@@ -49,7 +49,7 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
return snake if snake in known else None
def _display(value, translation_key: str | None):
def _display(value, translation_key: str | None, fallback_fn=None):
"""Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (it can itself
@@ -70,11 +70,19 @@ def _display(value, translation_key: str | None):
if translation_key:
known = translated_states("select", translation_key)
if not known:
# No state table for this key: either untranslated, or its
# options deliberately aren't (an unrecognized course table).
return value
if translated := _translation_state(value, known):
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
# aren't (an unrecognized course table, say). Give an explicit
# device-specific fallback the opportunity to make an opaque value
# readable; otherwise the raw device value remains the best choice.
if fallback_fn is None:
return value
elif translated := _translation_state(value, known):
return translated
if fallback_fn is not None:
fallback = fallback_fn(value)
if fallback is not None:
return fallback
if value.islower():
return value.replace("_", " ").title()
return _CAMEL_BOUNDARY_RE.sub(" ", value)
@@ -85,7 +93,16 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
super().__init__(coordinator, bound)
desc = cast(SelectDesc, bound.desc)
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
self._attr_options = [self._display_option(o) for o in desc.options]
def _display_option(self, value):
"""Normalize both current state and options through one path."""
display_fn = self._bound.desc.display_fn
fallback_fn = (
(lambda raw: display_fn(raw, self._resources))
if display_fn is not None else None
)
return _display(value, self.translation_key, fallback_fn)
def _raw_options(self) -> list[str]:
desc = cast(SelectDesc, self._bound.desc)
@@ -106,17 +123,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def options(self) -> list[str]:
desc = cast(SelectDesc, self._bound.desc)
if desc.options_field or callable(desc.options):
return [_display(o, self.translation_key) for o in self._raw_options()]
return [self._display_option(o) for o in self._raw_options()]
return self._attr_options
@property
def current_option(self):
raw = (self.coordinator.data or {}).get(self._state_key)
return _display(raw, self.translation_key)
return self._display_option(raw)
async def async_select_option(self, option: str) -> None:
raw = next(
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
(o for o in self._raw_options() if self._display_option(o) == option),
option,
)
await self.coordinator.async_send_command(self._bound, raw)
@@ -297,7 +297,13 @@
"07": "Předoplach",
"8d": "Hrnce a pánve",
"8e": "Plast",
"8f": "Dětské potřeby"
"8f": "Dětské potřeby",
"82": "Automatický",
"8a": "Normální",
"a7": "Intenzivní",
"a8": "Expresní",
"8c": "Extra tichý",
"88": "Samočištění"
}
},
"dispense_type": {
@@ -621,12 +627,30 @@
"60": "Samočištění+",
"65": "Barevné prádlo",
"66": "Džíny",
"69": "AI praní",
"6a": "Vlna",
"6b": "Džíny",
"6c": "Halenky",
"6d": "Jemné prádlo",
"6e": "Sportovní oblečení",
"6f": "Ložní prádlo",
"70": "Ručníky",
"71": "Rychlé praní",
"72": "Košile",
"73": "Dezinfekce",
"74": "Čištění bubnu",
"75": "Outdoor",
"76": "Dětské potřeby",
"77": "Bavlna",
"78": "Máchání + odstřeďování",
"79": "Pouze odstřeďování",
"7c": "Bílé prádlo",
"7d": "Ložní prádlo/nepromokavé",
"7e": "Samočištění",
"7f": "Vlna/jemné",
"86": "Hloubkové praní",
"87": "Stažený program",
"88": "Péče o domácí mazlíčky",
"8f": "Intenzivní studená",
"96": "Méně mikrovláken"
}
@@ -297,7 +297,13 @@
"07": "Pre blast",
"8d": "Pots and pans",
"8e": "Plastic",
"8f": "Baby Care"
"8f": "Baby Care",
"82": "Auto",
"8a": "Normal",
"a7": "Heavy",
"a8": "Express",
"8c": "Extra Silence",
"88": "Self Clean"
}
},
"dispense_type": {
@@ -621,12 +627,30 @@
"60": "Self Clean+",
"65": "Colors",
"66": "Denim",
"69": "AI Wash",
"6a": "Wool",
"6b": "Denim",
"6c": "Blouses",
"6d": "Delicates",
"6e": "Active Wear",
"6f": "Bedding",
"70": "Towels",
"71": "Quick Wash",
"72": "Shirts",
"73": "Sanitize",
"74": "Drum Clean",
"75": "Outdoor",
"76": "Baby Care",
"77": "Cottons",
"78": "Rinse + Spin",
"79": "Spin Only",
"7c": "Whites",
"7d": "Bedding/Waterproof",
"7e": "Self-Clean",
"7f": "Wool/Delicate",
"86": "Deep Wash",
"87": "Download",
"88": "Pet Care",
"8f": "Intense Cold",
"96": "Less Microfiber"
}
@@ -446,7 +446,16 @@
"07": "Prelavado intenso",
"8d": "Ollas y sartenes",
"8e": "Plástico",
"8f": "Cuidado del bebé"
"8f": "Cuidado del bebé",
"82": "Automático",
"8a": "Normal",
"a7": "Intenso",
"a8": "Express",
"8c": "Extra silencioso",
"88": "Autolimpieza",
"85": "Delicado",
"0c": "Express",
"0d": "Autolimpieza"
}
},
"dispense_type": {
@@ -488,7 +497,9 @@
"4c": "Renovación de aire",
"51": "Eco algodón",
"53": "Secado IA+",
"4e": "Autosecado"
"4e": "Autosecado",
"26": "Aireación",
"2a": "Cuidado higiénico+"
}
},
"favorite_capacity": {
@@ -777,7 +788,26 @@
"86": "Lavado profundo",
"87": "Descarga de Programas",
"8f": "Lavado en frío",
"96": "Menos microfibras"
"96": "Menos microfibras",
"69": "Lavado IA",
"6a": "Lana",
"6b": "Denim",
"6c": "Blusas",
"6d": "Delicados",
"6e": "Ropa deportiva",
"6f": "Ropa de cama",
"70": "Toallas",
"71": "Lavado rápido",
"72": "Camisas",
"73": "Desinfección",
"74": "Limpieza de Tambor",
"75": "Exterior",
"76": "Cuidado del bebé",
"77": "Algodón",
"78": "Aclarar + Centrifugar",
"79": "Solo centrifugar",
"88": "Cuidado de mascotas",
"35": "Algodón E"
}
},
"washer_dry_level": {
@@ -297,7 +297,16 @@
"07": "Prelavaggio intenso",
"8d": "Pentole e padelle",
"8e": "Plastica",
"8f": "Bambini"
"8f": "Bambini",
"82": "Automatico",
"8a": "Normale",
"a7": "Intenso",
"a8": "Express",
"8c": "Extra silenzioso",
"88": "Autopulizia",
"85": "Delicato",
"0c": "Express",
"0d": "Autopulizia"
}
},
"dispense_type": {
@@ -339,7 +348,9 @@
"4c": "Rinfresca",
"51": "Eco cotone",
"53": "Asciugatura AI+",
"4e": "Autoasciugatura"
"4e": "Autoasciugatura",
"26": "Arieggiatura",
"2a": "Cura igienica+"
}
},
"favorite_capacity": {
@@ -628,7 +639,26 @@
"86": "Lavaggio profondo",
"87": "Scaricato",
"8f": "Intenso a freddo",
"96": "Riduci microfibre"
"96": "Riduci microfibre",
"69": "Lavaggio AI",
"6a": "Lana",
"6b": "Jeans",
"6c": "Camicette",
"6d": "Delicati",
"6e": "Abbigliamento sportivo",
"6f": "Biancheria da letto",
"70": "Asciugamani",
"71": "Lavaggio rapido",
"72": "Camicie",
"73": "Igienizzante",
"74": "Pulizia cestello",
"75": "Capi outdoor",
"76": "Bambini",
"77": "Cotone",
"78": "Risciacquo+Centrifuga",
"79": "Solo centrifuga",
"88": "Cura animali",
"35": "Cotone E"
}
},
"washer_dry_level": {
@@ -297,7 +297,16 @@
"07": "애벌 세척",
"8d": "냄비 및 팬",
"8e": "플라스틱",
"8f": "젖병 살균"
"8f": "젖병 살균",
"82": "자동",
"8a": "표준",
"a7": "강력",
"a8": "급속",
"8c": "저소음",
"88": "내부 세척",
"85": "섬세",
"0c": "급속",
"0d": "내부 세척"
}
},
"dispense_type": {
@@ -339,7 +348,9 @@
"4c": "에어 리프레시",
"51": "에코 면",
"53": "AI 맞춤건조+",
"4e": "자체 건조"
"4e": "자체 건조",
"26": "송풍",
"2a": "살균건조+"
}
},
"favorite_capacity": {
@@ -628,7 +639,26 @@
"86": "찌든 때 세탁",
"87": "다운로드",
"8f": "강력 냉수 세탁",
"96": "미세플라스틱저감"
"96": "미세플라스틱저감",
"69": "AI 맞춤세탁",
"6a": "울",
"6b": "데님",
"6c": "블라우스",
"6d": "섬세의류",
"6e": "운동복",
"6f": "이불",
"70": "타월",
"71": "쾌속세탁",
"72": "셔츠",
"73": "살균",
"74": "무세제통세척",
"75": "아웃도어",
"76": "아기옷",
"77": "면",
"78": "헹굼+탈수",
"79": "탈수만",
"88": "펫케어",
"35": "에코 면"
}
},
"washer_dry_level": {
@@ -297,7 +297,13 @@
"07": "Voorspoelen",
"8d": "Potten en pannen",
"8e": "Kunststof",
"8f": "Babyverzorging"
"8f": "Babyverzorging",
"82": "Auto",
"8a": "Normaal",
"a7": "Intensief",
"a8": "Express",
"8c": "Extra stil",
"88": "Zelfreiniging"
}
},
"dispense_type": {
@@ -621,12 +627,30 @@
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"69": "AI Wash",
"6a": "Wol",
"6b": "Spijkergoed",
"6c": "Blouses",
"6d": "Fijne was",
"6e": "Sportkleding",
"6f": "Beddengoed",
"70": "Handdoeken",
"71": "Snelle was",
"72": "Overhemden",
"73": "Hygiëne",
"74": "Trommel reinigen",
"75": "Outdoor",
"76": "Babyverzorging",
"77": "Katoen",
"78": "Spoelen + centrifugeren",
"79": "Alleen centrifugeren",
"7c": "Witte was",
"7d": "Beddengoed/waterdicht",
"7e": "Self Clean",
"7f": "Wol/fijne was",
"86": "Diep wassen",
"87": "Gedownload",
"88": "Pet Care",
"8f": "Intensief koud",
"96": "Minder microvezels"
}