Compare commits

..
Author SHA1 Message Date
Claude ac93549f2a Retry /<uuid>/information/vs/0 in the flat fallback so siblings get their own model/serial
Cloning the master's hrefs/values verbatim (issue #265) also clones its
/information/vs/0 -- so a sibling's HA device page showed the master's
own model and serial number, reading as a duplicate device even though
the two devices' registry identifiers are genuinely distinct (they're
derived from the subdevice's UUID, not from this resource).

/information/vs/0 is also the one href this pattern has ever actually
been confirmed to answer on its own (issue #177 comment 5113518087), so
the fallback now retries that one href specifically -- a single bounded
RETRIEVE per candidate, not the ~30 that caused the original hang -- and
uses the real reply in place of the clone when it answers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuiJvThhi4WkNj2xb9xo8o
2026-08-03 13:06:02 +00:00
Claude b32074c8dd Stop probing every href for Pattern B/C subdevice fallback (issue #265)
When /<uuid>/device/0 doesn't answer for a UUID-prefixed subdevice
candidate, enumerate_subdevices used to fall back to probing every href
the master answered that cycle individually under the prefix. Firmware
that drops packets instead of 4.04ing (issue #265's ARTIK051_FAC_TIME_21K)
turned that ~30-href loop into a ~300s hang at the default 10s RETRIEVE
timeout, tripping Home Assistant's bootstrap-stage timeout and failing
the whole config entry before the master device even finished setup.

The fallback now trusts the device's own subdeviceIdList/oic_res-link
claim outright instead of re-confirming it href by href: it clones the
master's current hrefs and values verbatim under the prefix as the
sibling's assumed state, with no extra RETRIEVEs. The existing
per-summary-poll re-poll (_poll_subdevice_flat_hrefs) is unchanged and
corrects the clone toward the sibling's real values as they're confirmed
live; an href the firmware never answers under that prefix just keeps
mirroring the master indefinitely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuiJvThhi4WkNj2xb9xo8o
2026-08-03 12:49:58 +00:00
45 changed files with 355 additions and 3372 deletions

No files matched your search

@@ -399,24 +399,6 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
**Don't write a test that just re-asserts a translation string.** Adding
labels is a data change, not a logic change, and `tests/test_translations.py`
already holds the invariants that matter for data (every descriptor has a
catalog entry, every language mirrors English key-for-key, no unresolved
`[%key:...%]`). A test that loads the catalog and asserts
`catalog["select"]["foo"]["state"]["16"] == "Cotton"` right after you just
wrote that exact line into `en.json` doesn't exercise any code path — it
re-states the JSON file in Python, passes by construction, and only ever
fails when someone *correctly* edits the label later (a wording fix, a
translator's improvement). It's not a regression test, because there's no
`select.py`/`adapter.py` logic between "the JSON says X" and "the test reads
X" for it to catch drift in. If a code/label mapping is worth locking in,
test it through the code that actually consumes it instead — a write
contract (`desc.write_fn(...)` returns the right raw code), a read contract
(`flatten()` produces the right raw value from a fixture rep), or a routing
decision — never a bare literal-string comparison against the catalog you
just edited.
## 8. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
+4 -33
View File
@@ -128,23 +128,6 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
_AI_COMFORT_MODE = "AIComfort"
PRESET_AI_COMFORT = "ai_comfort"
# Codes that appear in /mode/vs/0's supportedModes but are option/capability
# flags rather than selectable thermodynamic operations -- dropped silently
# (no _warn_unmapped call) rather than every owner of an affected unit
# tripping the issue #93 warning on every start.
#
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in
# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside
# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a
# mode, on that resource. The unit's own `modes` (current mode) never
# reported it as active across the reporter's logs, only ever a real
# thermodynamic mode -- consistent with it being echoed into supportedModes
# rather than genuinely selectable. Unlike _AI_COMFORT_MODE above, it isn't
# modeled as a preset: there's no confirmation it's user-selectable at all,
# so silently dropping it (rather than guessing a write contract) is the
# 'don't guess' rule applied to a mode code instead of a resource field.
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
@@ -380,15 +363,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no
entry in the relevant device<->HA map, so a real device gap surfaces
in the log instead of silently vanishing (issue #93).
Falls back to `unique_id` when `entity_id` is unset (issue #235):
this fires during setup's first discovery pass, before the entity is
added to hass, so `entity_id` is always None at that point --
indistinguishable across multiple same-type devices in the log.
`unique_id` is set eagerly in `__init__` (see entity.py), so it's
always available here even though `entity_id` isn't.
"""
in the log instead of silently vanishing (issue #93)."""
key = (href, code)
if key in self._warned_unmapped:
return
@@ -396,7 +371,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
_LOGGER.warning(
"%s: device mode %r on %s has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id or self.unique_id,
self.entity_id,
code,
href,
)
@@ -497,11 +472,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
if device == _AI_COMFORT_MODE:
return HVACMode.AUTO
if (
device is not None
and device not in _DEVICE_TO_HVAC
and device not in _NON_HVAC_OPTION_CODES
):
if device is not None and device not in _DEVICE_TO_HVAC:
self._warn_unmapped(MODE_HREF, device)
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
@@ -509,7 +480,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def hvac_modes(self) -> list[HVACMode]:
modes = [HVACMode.OFF]
for m in self._supported(MODE_HREF):
if m == _AI_COMFORT_MODE or m in _NON_HVAC_OPTION_CODES:
if m == _AI_COMFORT_MODE:
continue
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is None:
+9 -5
View File
@@ -462,10 +462,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
(issue #205) -- it has no Collection endpoint to batch-refresh
through (see enumerate_subdevices' fallback), so each canonical
href confirmed at enumeration time gets its own GET under the
subdevice's prefix. A href failing to answer this cycle just drops
out of the result, same "never let a sibling's flakiness fail the
master's poll" posture as the Collection path above.
href assumed at enumeration time (cloned from the master's own
state, issue #265 -- not yet confirmed live under this subdevice's
own prefix) gets its own GET under the subdevice's prefix, which is
what turns the clone into this sibling's real value once it
answers. A href failing to answer this cycle just drops out of the
result and leaves whatever value is already cached (the clone, until
corrected) in place -- same "never let a sibling's flakiness fail
the master's poll" posture as the Collection path above.
Takes `sess` from the caller (already None-checked there) rather
than re-reading self._session -- async_close() can null that
@@ -477,7 +481,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
this method builds) -- those are already refreshed every 3s/6s by
_run_subpolls, strictly more current than this once-per-summary-poll
pass could offer, so re-fetching them here would only add GETs
without adding freshness. A subdevice with many confirmed hrefs
without adding freshness. A subdevice with many assumed hrefs
(unlike a Collection batch, which is always one GET regardless of
count) is otherwise a summary-poll cost that scales with its href
count."""
@@ -259,7 +259,6 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
"oic.d.refrigerator": "refrigerator",
"oic.d.washer": "washer",
"x.com.st.d.airqualitysensor": "air_monitor",
"x.com.st.d.dehumidifier": "dehumidifier",
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
"x.com.st.d.stickcleaner": "vacuum_station",
"x.com.st.d.steamcloset": "air_dresser",
@@ -33,11 +33,8 @@ REGISTRY = DeviceRegistry(
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_FILTER_PM1,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.UV_LED,
airconditioner.VENTILATION_ALARM,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
@@ -2,13 +2,10 @@
Shares the DA_AC_ board family with airconditioner.py (power, air filter,
auto-clean, mute-once all use the identical resource shapes), so those three
Capability objects are reused directly rather than duplicated. The
TP1X_DA_AC_DHM_01001_0000 revision (issues #271/#231) also reports
air_purifier.py's screen-on/off resource on the identical href/shape, so
that's reused too rather than re-defined.
Capability objects are reused directly rather than duplicated.
"""
from ..capabilities import air_purifier, airconditioner, common, dehumidifier, ignored
from ..capabilities import airconditioner, common, dehumidifier, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
@@ -20,11 +17,9 @@ REGISTRY = DeviceRegistry(
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
dehumidifier.WATERTANK_LIGHTING,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
air_purifier.DISPLAY,
*dehumidifier.COVERAGE,
]
),
@@ -28,7 +28,6 @@ REGISTRY = DeviceRegistry(
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DEFINITE_TEMPERATURE_FREEZER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
@@ -909,80 +909,6 @@ AIR_FILTER = Capability(
),
)
def _pm1_threshold_write(payload, rep, href=None):
"""Same contract as _threshold_write, against this filter's own href --
not yet confirmed live (no dump seen advertises
supportedFilterDesiredUsage for this href), so the Select this backs
stays gated behind that field's presence, same as AIR_FILTER's."""
return ["filter", "airdustPM1filter", "vs", "0"], {
"x.com.samsung.da.filterDesiredUsage": payload,
}
def _has_filter_field(field):
return lambda rep, resources: rep.get(field) is not None
# Second, PM1-rated dust filter some TP1X_FAC-class boards report alongside
# AIR_FILTER's /filter/airdustfilter/vs/0. TP1X_FAC_TIME_23K (issue #270)
# reports only filterCapacity/filterCapacityUnit/filterResetType
# ('notresetable') on this href, with no live filterUsage/filterStatus at
# all -- but the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191) reports
# the identical href with full live fields. So this stays a real capability
# rather than a blanket ignore, with every entity individually gated on its
# own field's presence per the 'don't guess' rule: no entity on a board that
# has nothing behind it, real readings on one that does.
AIR_FILTER_PM1 = Capability(
href="/filter/airdustPM1filter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="air_filter_pm1_usage",
rep_fn=filter_usage_percent,
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"),
),
SensorDesc(
key="air_filter_pm1_usage_hours",
field="x.com.samsung.da.filterUsage",
device_class="duration",
state_class="total_increasing",
unit_fn=_filter_unit,
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=_int,
exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"),
),
SelectDesc(
key="air_filter_pm1_threshold",
field="x.com.samsung.da.filterDesiredUsage",
options_field="x.com.samsung.da.supportedFilterDesiredUsage",
exists_fn=lambda rep, res: bool(
rep.get("x.com.samsung.da.supportedFilterDesiredUsage")
),
icon="mdi:alarm",
entity_category="config",
write_fn=_pm1_threshold_write,
value_fn=lambda v: str(v) if v is not None else None,
),
SensorDesc(
key="air_filter_pm1_status",
field="x.com.samsung.da.filterStatus",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
exists_fn=_has_filter_field("x.com.samsung.da.filterStatus"),
),
),
)
DISPLAY_LIGHT = Capability(
href="/light/vs/0",
poll_tier="cold",
@@ -1001,51 +927,6 @@ DISPLAY_LIGHT = Capability(
),
)
# UV-C sterilization LED (issue #270, TP1X_FAC_TIME_23K). Same On/Off
# convention as everything else on this board, and (unlike VENTILATION_ALARM
# below) a real supportedModes list confirms the value set.
UV_LED = Capability(
href="/uvled/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="uv_led",
field="x.com.samsung.da.modes",
icon="mdi:lightbulb-fluorescent-tube",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["uvled", "vs", "0"],
{"x.com.samsung.da.modes": "On" if p == "On" else "Off"},
),
),
),
)
# Ventilation-reminder alarm toggle (issue #270). Only a bare `alarm` field
# is present -- no supportedModes list to confirm the value set against,
# unlike UV_LED above. Modeled as a switch on the strength of the same
# On/Off convention used everywhere else in this API; a rejected write is
# the worst case for a wrong guess (see the adding-device-support skill),
# but this hasn't been round-trip confirmed on real hardware.
VENTILATION_ALARM = Capability(
href="/ventilation/setting/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="ventilation_alarm",
field="alarm",
icon="mdi:bell-alert",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["ventilation", "setting", "vs", "0"],
{"alarm": "On" if p == "On" else "Off"},
),
),
),
)
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single
# boolean field, no vendor prefix, mirroring the On/Off convention used
# throughout the rest of this API.
@@ -9,7 +9,7 @@ entities rather than folded into one card.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from ..entities import NumberDesc, SelectDesc, SensorDesc
from .common import int_or_none
@@ -77,60 +77,6 @@ HUMIDITY = Capability(
),
)
# Water-tank ambient light (issues #271/#231, TP1X_DA_AC_DHM_01001_0000):
# on/off, color, and brightness are three independent controls on this one
# resource. `waterfullAlarmStatus` differs between the two dumps that
# reported this href (On vs. Off) so it's a real live flag, not a constant --
# but its exact meaning (tank actually full vs. the chime feature merely
# enabled) isn't confirmed by either dump alone, and /alarms/vs/0's
# alarm_code already surfaces a live WaterTankFull condition when one fires
# (see common._active_alarm_codes), so this is exposed read-only as a plain
# diagnostic value rather than guessed at as a binary_sensor.
WATERTANK_LIGHTING = Capability(
href="/watertank/lighting/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="watertank_light",
field="status",
icon="mdi:led-on",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="watertank_light_color",
field="colorOption",
icon="mdi:palette",
entity_category="config",
options_field="colorSupportedList",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"colorOption": p},
),
),
SelectDesc(
key="watertank_light_brightness",
field="mode",
icon="mdi:brightness-6",
entity_category="config",
options_field="modeSupportedList",
write_fn=lambda p, rep, href=None: (
["watertank", "lighting", "vs", "0"],
{"mode": p},
),
),
SensorDesc(
key="watertank_full_alarm_status",
field="waterfullAlarmStatus",
entity_category="diagnostic",
),
),
)
# ---------------------------------------------------------------------------
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
# or no documented write contract, following the same 'don't guess' rule as
@@ -48,18 +48,6 @@ DISHWASHER_SETTINGS = Capability(
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
# use). Course display names live in translations under
# entity.select.dishwasher_cycle (see laundry.cycle_select).
#
# '83'/'86' were transposed in that catalog until issue #226: both the
# original DW9000F-class fixture this table was built from and the issue
# #226 reporter's board report the identical DeviceType_0812 (a real
# per-board-generation id also seen on unrelated washer/dryer fixtures, so
# this is one shared course table, not a Table_02/Table_03-style generation
# split), and the original fixture's own live editCourseList
# ('EditCourseList_0E07908683848D808E8F') puts '86' and '83' back to back at
# positions 4-5 -- exactly the kind of adjacent pair a manual screenshot
# transcription slips on. The reporter's live confirmation (selecting
# 'Normal' ran the physical Express 60 program and vice versa) settles
# which way: '86' is Express 60, '83' is Normal.
# ---------------------------------------------------------------------------
CYCLE_OPTIONS = Capability(
@@ -11,7 +11,7 @@ the /course/vs/0 cycle select -- lives in laundry.py.
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
@@ -41,25 +41,14 @@ DRYER_SETTINGS = Capability(
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes '01' Normal and '06' Time dry were confirmed on a
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. Codes '01' Normal and '06' Time dry were confirmed on a
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
# appliance and reading back the raw code from the entity's state (issue
# #80). Codes '51' Eco Cotton, '53' AI Dry+, and '4e' Self Dry were
# confirmed the same way on a DV90DG6845LHU5 (issue #244). The
# /st/dryercourse/vs/0 resource re-encodes the same selected course and is
# ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 is ignored
# for washers.
#
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/
# drum_clean_last_cleaned's docstrings for the field contract, including
# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No
# separate heat-exchanger-clean tracking was found on either dump #258
# supplied (DV90BB7445GES7, DV91T6440LE/SA) -- no HeatExchanger*-prefixed
# token, nor any other options[] entry that looks like a second maintenance
# counter -- so if the Samsung app surfaces that reminder for these units,
# it isn't computed from anything this integration can read locally.
# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
href="/course/vs/0",
entities=(
@@ -68,22 +57,6 @@ DRYER_COURSE = Capability(
icon="mdi:tumble-dryer",
table_href="/st/dryercourse/vs/0",
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:tumble-dryer-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
rep_fn=drum_clean_last_cleaned,
),
),
)
@@ -163,34 +163,6 @@ DEFINITE_TEMPERATURE_COOLER = Capability(
),
)
def _definite_freezer_write(p, rep, href=None):
return (
["temperature", "definite", "freezer", "vs", "0"],
{"x.com.samsung.da.definite.desired": p},
)
# Freezer half of the same discrete-setpoint pattern (issue #229): a
# fridge/freezer combo reporting no /temperature/current|desired/freezer
# pair, only this bundled vendor resource -- identical shape to
# DEFINITE_TEMPERATURE_COOLER above (down to the field names), just negative
# supportedList values (e.g. ['-23','-21','-19','-17','-15']).
DEFINITE_TEMPERATURE_FREEZER = Capability(
href="/temperature/definite/freezer/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="freezer_temperature_setpoint",
field="x.com.samsung.da.definite.desired",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.definite.supportedList",
write_fn=_definite_freezer_write,
),
),
)
# ---------------------------------------------------------------------------
# Icemaker nighttime quiet mode
# ---------------------------------------------------------------------------
@@ -20,7 +20,6 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
"""
from datetime import UTC, datetime
from datetime import time as dt_time
from ...catalog import has_entity_translation
@@ -235,47 +234,6 @@ def option_value(options, prefix):
return None
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258); both families use identical DrumCleanProposal_/WashingTimes_/
# DrumCleanLog_ tokens. DrumCleanProposal_<N> is the wash/dry-cycle interval
# between recommended cleans; WashingTimes_<N> is the count since the last
# one -- their difference is exactly the "N cycles until due" figure the
# Samsung app shows (verified on a washer: DrumCleanProposal_40 -
# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia
# po 37 cykloch").
def drum_clean_cycles_remaining(rep):
opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal")
washed = option_value(opts, "WashingTimes")
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
# datetime (the last clean, verified against the same app screenshot's "10
# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a
# '|'-joined history of every past clean, ten deep on that dump, in
# strictly increasing order. Splitting on '|' and taking the last element
# handles both shapes identically -- a no-'|' value is unaffected. No
# explicit timezone field accompanies either shape, so it's treated as UTC,
# matching this integration's convention for other bare ISO datetime fields
# (see fridge.py's night-light schedule comment).
def drum_clean_last_cleaned(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw:
return None
last = raw.rsplit("|", 1)[-1]
try:
return datetime.fromisoformat(last).replace(tzinfo=UTC)
except ValueError:
return None
def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
@@ -15,6 +15,8 @@ here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
from datetime import UTC, datetime
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
@@ -22,8 +24,6 @@ from .laundry import (
bool_option_switch,
cycle_options,
cycle_select,
drum_clean_cycles_remaining,
drum_clean_last_cleaned,
hex_pairs,
option_value,
option_write,
@@ -149,10 +149,36 @@ WASHER_SETTINGS = Capability(
# ---------------------------------------------------------------------------
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
# #258) since both families report identical DrumCleanProposal_/
# WashingTimes_/DrumCleanLog_ tokens on the same options[] array.
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
# recommended cleans; WashingTimes_<N> is the count since the last one --
# their difference is exactly the "N cycles until due" figure the Samsung
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
# is the last-clean timestamp (verified against the same screenshot's "10
# days ago"); no explicit timezone field accompanies it on this resource,
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal")
washed = option_value(opts, "WashingTimes")
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
def _drum_clean_last_cleaned(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=UTC)
except ValueError:
return None
# Detergent/softener auto-dispense dosing, from the same options[] array
@@ -314,16 +340,16 @@ WASHER_COURSE = Capability(
unit="cycles",
icon="mdi:washing-machine-alert",
state_class="measurement",
exists_fn=lambda rep, resources: drum_clean_cycles_remaining(rep) is not None,
rep_fn=drum_clean_cycles_remaining,
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: drum_clean_last_cleaned(rep) is not None,
rep_fn=drum_clean_last_cleaned,
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned,
),
SelectDesc(
key="detergent_quantity",
@@ -30,11 +30,37 @@ second subdevice exists at that prefix, but not evidence that `GET
around) itself returns anything. Issue #205, the same unit on a later
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
So `enumerate_subdevices` tries it first (a future board might genuinely
expose it) and falls back, when it's empty, to probing every href the
master itself answered this cycle individually under the UUID prefix --
the only thing ever actually confirmed to work for this pattern -- on the
assumption that a composite device's siblings share the master's resource
surface. See `Subdevice.flat_hrefs`.
expose it) and, when it's empty, assumes the sibling shares the master's
resource surface outright rather than trying to confirm it href by href.
That per-href confirmation is what this module did originally, and issue
#265 is why it doesn't anymore: `ARTIK051_FAC_TIME_21K`'s firmware doesn't
4.04 an href it doesn't recognize under a foreign prefix, it drops the
packet -- so probing the ~30 hrefs a real AC dump carries, each waiting out
the full 10s RETRIEVE timeout before moving on, took close enough to five
minutes to trip Home Assistant's own bootstrap-stage timeout and fail the
whole config entry, main unit included, before a single entity existed.
A device naming this UUID in its own `subdeviceIdList` (or `/oic/res` link
prefix, Pattern C) is already the device's own claim that a real subdevice
lives there; spending an unbounded, firmware-dependent amount of RETRIEVEs
trying to re-confirm that claim one href at a time bought unreliable
confirmation at a cost that could take the integration down with it. So the
fallback now trusts the claim and clones the master's current hrefs *and*
values verbatim under the prefix as this cycle's assumed state for the
sibling, with one bounded exception: it still retries a single individual
`GET /<uuid>/information/vs/0` -- the one href this pattern has ever
actually confirmed to answer on its own, per the paragraph above -- so the
sibling's HA device shows its own model/serial rather than the master's
cloned ones (identical model/serial across two otherwise-distinct devices
reads as a duplicate, even though their device-registry identifiers are
genuinely different). That's one extra RETRIEVE per candidate, not the ~30
that caused issue #265. `coordinator._poll_subdevice_flat_hrefs` still
re-GETs every cloned href individually, under the prefix, on every later
summary poll (unchanged by this), correcting the assumption toward the
sibling's real values as responses arrive; an href the firmware never
answers under that prefix just keeps mirroring the master indefinitely --
which, for a board proven not to expose that href at all, is what "assume
it exists" has to mean in practice. See `Subdevice.flat_hrefs`.
Pattern C -- UUID prefix advertised only via `/oic/res`
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
@@ -134,10 +160,13 @@ class Subdevice:
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
expose its own Collection at `seed_path` (issue #205 -- not even
TP2X_FAC_BORA_21K, the board this pattern was built against, always
does). When set, `seed_path` is meaningless (left as `()`) and this
subdevice's state comes from GETting each of these canonical hrefs
individually under its prefix instead of one Collection batch -- see
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
does). When set, `seed_path` is meaningless (left as `()`); enumeration
seeds this subdevice's initial state by cloning the master's own current
hrefs/values under its prefix rather than confirming them live (issue
#265 -- see enumerate_subdevices' fallback), and from then on
coordinator._poll_subdevice_seed refreshes it by GETting each of these
canonical hrefs individually under its prefix instead of one Collection
batch, correcting the clone toward real values as responses arrive.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
@@ -379,49 +408,63 @@ def enumerate_subdevices(
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
return
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
# Fallback (issue #265, superseding the per-href probe issue #205
# originally added here): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
# has one" doesn't hold even on the reference hardware. 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 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. Each is a
# plain tolerated-404 RETRIEVE, same posture as every other probe in
# this function.
#
# Known gap, not yet guarded against: a firmware that answers *any*
# request under an unrecognized prefix (echoing the master's own
# state back rather than 4.04ing) would pass every one of these
# probes and, if the echoed state also clears discover_partitioned's
# liveness gate, materialize a phantom duplicate of the master
# rather than a real sibling. Every board seen so far genuinely
# 4.04s on paths it doesn't own (issue #205's own unit answered only
# 1 of 31 probes), so this hasn't been built -- the one place it
# could hook in later is comparing a candidate's confirmed reps
# against the master's own values for those same canonical hrefs.
flat_hrefs = []
first = True
for href in sorted(resources):
if not first:
sess.pace()
first = False
actual = f"/{sub_id}{href}"
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
_probed(actual, rep)
if rep:
flat_hrefs.append(href)
fetched[actual] = rep
# has one" doesn't hold even on the reference hardware. This used to
# respond by probing every href the master itself answered this
# cycle, individually, under this UUID's prefix, keeping whichever
# ones answered -- but issue #265's ARTIK051_FAC_TIME_21K drops
# packets for hrefs it doesn't recognize under a foreign prefix
# instead of 4.04ing them, so that loop's ~30 RETRIEVEs (a real AC
# dump's href count) each ran out the full 10s timeout, took close
# to five minutes, and tripped Home Assistant's own bootstrap-stage
# timeout -- failing the whole config entry, main unit included,
# before a single entity existed. The device already named this
# UUID in its own subdeviceIdList (or an /oic/res link prefix,
# Pattern C) -- that's the device's own claim a real subdevice lives
# there, so trust it outright rather than spend an unbounded,
# firmware-dependent number of RETRIEVEs re-confirming it href by
# href: clone the master's current hrefs and values verbatim under
# this prefix as the sibling's assumed state, no per-href probing
# loop at all. coordinator._poll_subdevice_flat_hrefs re-GETs each of
# these hrefs individually, under the prefix, on every later summary
# poll (unchanged by this), correcting the clone toward the
# sibling's real values as responses arrive; an href the firmware
# never answers under this prefix just keeps mirroring the master
# indefinitely -- the necessary meaning of "assume it exists" for a
# board already proven not to expose that href at all.
flat_hrefs = tuple(sorted(resources))
if not flat_hrefs:
return
for href in flat_hrefs:
fetched[f"/{sub_id}{href}"] = resources[href]
# One exception, worth its own bounded RETRIEVE rather than trusting
# the clone: /information/vs/0 is what device_info_for() reads for
# this subdevice's own model/serial. Left cloned from the master,
# the sibling's HA device page would show the *master's* model and
# serial verbatim -- distinct device-registry identifiers (derived
# from this UUID, see device_info_for), but reading as a duplicate
# of the master to anyone looking at the two devices' info. This is
# also the one href ever actually confirmed to answer under this
# pattern's prefix on real hardware (issue #177 comment 5113518087),
# so it's worth retrying here even though the rest of the fallback
# no longer probes anything -- one extra RETRIEVE per candidate,
# not the ~30 that caused issue #265.
info_seed = (sub_id, "information", "vs", "0")
info = _get_property(sess, info_seed)
_probed(_seed_href(info_seed), info)
if info:
fetched[f"/{sub_id}/information/vs/0"] = info
if "/information/vs/0" not in flat_hrefs:
flat_hrefs = tuple(sorted({*flat_hrefs, "/information/vs/0"}))
subdevices.append(
Subdevice(
kind="prefixed",
key=sub_id,
seed_path=(),
flat_hrefs=tuple(flat_hrefs),
flat_hrefs=flat_hrefs,
)
)
@@ -278,9 +278,9 @@
"name": "Cyklus",
"state": {
"80": "Jemné",
"83": "Normální",
"83": "Express 60",
"84": "Intenzivní",
"86": "Express 60",
"86": "Normální",
"90": "Samočištění",
"0e": "AI mytí",
"07": "Předoplach",
@@ -325,10 +325,7 @@
"1e": "Outdoor",
"1f": "Smíšená náplň",
"2b": "Sušení bubnu",
"4c": "Osvěžení vzduchem",
"51": "Eko bavlna",
"53": "AI sušení+",
"4e": "Automatické sušení"
"4c": "Osvěžení vzduchem"
}
},
"favorite_capacity": {
@@ -624,28 +621,6 @@
},
"zone_mode": {
"name": "Režim zóny"
},
"watertank_light_color": {
"name": "Barva osvětlení nádržky na vodu",
"state": {
"warmwhite": "Teplá bílá",
"naturalwhite": "Přirozená bílá",
"coolwhite": "Studená bílá",
"green": "Zelená",
"purple": "Fialová",
"blue": "Modrá",
"orange": "Oranžová",
"pink": "Růžová"
}
},
"watertank_light_brightness": {
"name": "Jas osvětlení nádržky na vodu"
},
"air_filter_pm1_threshold": {
"name": "Práh alarmu PM1 filtru"
},
"freezer_temperature_setpoint": {
"name": "Teplota mrazicí zóny"
}
},
"sensor": {
@@ -994,15 +969,6 @@
},
"zone_temperature": {
"name": "Teplota zóny"
},
"watertank_full_alarm_status": {
"name": "Stav alarmu plné nádržky na vodu"
},
"air_filter_pm1_usage": {
"name": "Využití PM1 filtru"
},
"air_filter_pm1_usage_hours": {
"name": "Hodiny využití PM1 filtru"
}
},
"switch": {
@@ -1170,15 +1136,6 @@
},
"away_mode": {
"name": "Režim nepřítomnosti"
},
"watertank_light": {
"name": "Osvětlení nádržky na vodu"
},
"uv_led": {
"name": "UV LED"
},
"ventilation_alarm": {
"name": "Alarm větrání"
}
},
"time": {
@@ -278,9 +278,9 @@
"name": "Cycle",
"state": {
"80": "Delicate",
"83": "Normal",
"83": "Express 60",
"84": "Heavy",
"86": "Express 60",
"86": "Normal",
"90": "Self clean",
"0e": "AI Wash",
"07": "Pre blast",
@@ -325,10 +325,7 @@
"1e": "Outdoor",
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh",
"51": "Eco Cotton",
"53": "AI Dry+",
"4e": "Self Dry"
"4c": "Air Refresh"
}
},
"favorite_capacity": {
@@ -624,28 +621,6 @@
},
"zone_mode": {
"name": "Zone mode"
},
"watertank_light_color": {
"name": "Water tank light color",
"state": {
"warmwhite": "Warm white",
"naturalwhite": "Natural white",
"coolwhite": "Cool white",
"green": "Green",
"purple": "Purple",
"blue": "Blue",
"orange": "Orange",
"pink": "Pink"
}
},
"watertank_light_brightness": {
"name": "Water tank light brightness"
},
"air_filter_pm1_threshold": {
"name": "PM1 filter alarm threshold"
},
"freezer_temperature_setpoint": {
"name": "Freezer temperature"
}
},
"sensor": {
@@ -994,15 +969,6 @@
},
"zone_temperature": {
"name": "Zone temperature"
},
"watertank_full_alarm_status": {
"name": "Water tank full alarm status"
},
"air_filter_pm1_usage": {
"name": "PM1 filter usage"
},
"air_filter_pm1_usage_hours": {
"name": "PM1 filter usage hours"
}
},
"switch": {
@@ -1170,15 +1136,6 @@
},
"away_mode": {
"name": "Away mode"
},
"watertank_light": {
"name": "Water tank light"
},
"uv_led": {
"name": "UV LED"
},
"ventilation_alarm": {
"name": "Ventilation alarm"
}
},
"time": {
@@ -392,9 +392,9 @@
"name": "Ciclo",
"state": {
"80": "Delicado",
"83": "Normal",
"83": "Express 60",
"84": "Intenso",
"86": "Express 60",
"86": "Normal",
"90": "Autolimpieza",
"0e": "Lavado IA",
"07": "Prelavado intenso",
@@ -439,10 +439,7 @@
"1e": "Exterior",
"1f": "Carga mixta",
"2b": "Secado de tambor",
"4c": "Renovación de aire",
"51": "Eco algodón",
"53": "Secado IA+",
"4e": "Autosecado"
"4c": "Renovación de aire"
}
},
"favorite_capacity": {
@@ -738,28 +735,6 @@
},
"zone_mode": {
"name": "Modo de zona"
},
"watertank_light_color": {
"name": "Color de la luz del depósito de agua",
"state": {
"warmwhite": "Blanco cálido",
"naturalwhite": "Blanco natural",
"coolwhite": "Blanco frío",
"green": "Verde",
"purple": "Púrpura",
"blue": "Azul",
"orange": "Naranja",
"pink": "Rosa"
}
},
"watertank_light_brightness": {
"name": "Brillo de la luz del depósito de agua"
},
"air_filter_pm1_threshold": {
"name": "Umbral de alarma del filtro PM1"
},
"freezer_temperature_setpoint": {
"name": "Temperatura del congelador"
}
},
"sensor": {
@@ -1108,15 +1083,6 @@
},
"zone_temperature": {
"name": "Temperatura de zona"
},
"watertank_full_alarm_status": {
"name": "Estado de alarma de depósito de agua lleno"
},
"air_filter_pm1_usage": {
"name": "Uso del filtro PM1"
},
"air_filter_pm1_usage_hours": {
"name": "Horas de uso del filtro PM1"
}
},
"switch": {
@@ -1284,15 +1250,6 @@
},
"zone_power": {
"name": "Alimentación de zona"
},
"watertank_light": {
"name": "Luz del depósito de agua"
},
"uv_led": {
"name": "LED UV"
},
"ventilation_alarm": {
"name": "Alarma de ventilación"
}
},
"time": {
@@ -278,9 +278,9 @@
"name": "Ciclo",
"state": {
"80": "Delicato",
"83": "Normale",
"83": "Express 60",
"84": "Intenso",
"86": "Express 60",
"86": "Normale",
"90": "Autopulizia",
"0e": "AI Wash",
"07": "Prelavaggio intenso",
@@ -325,10 +325,7 @@
"1e": "Capi outdoor",
"1f": "Carico misto",
"2b": "Asciugatura cestello",
"4c": "Rinfresca",
"51": "Eco cotone",
"53": "Asciugatura AI+",
"4e": "Autoasciugatura"
"4c": "Rinfresca"
}
},
"favorite_capacity": {
@@ -624,28 +621,6 @@
},
"zone_mode": {
"name": "Modalità zona"
},
"watertank_light_color": {
"name": "Colore della luce del serbatoio dell'acqua",
"state": {
"warmwhite": "Bianco caldo",
"naturalwhite": "Bianco naturale",
"coolwhite": "Bianco freddo",
"green": "Verde",
"purple": "Viola",
"blue": "Blu",
"orange": "Arancione",
"pink": "Rosa"
}
},
"watertank_light_brightness": {
"name": "Luminosità della luce del serbatoio dell'acqua"
},
"air_filter_pm1_threshold": {
"name": "Soglia di allarme del filtro PM1"
},
"freezer_temperature_setpoint": {
"name": "Temperatura freezer"
}
},
"sensor": {
@@ -994,15 +969,6 @@
},
"zone_temperature": {
"name": "Temperatura zona"
},
"watertank_full_alarm_status": {
"name": "Stato allarme serbatoio d'acqua pieno"
},
"air_filter_pm1_usage": {
"name": "Utilizzo del filtro PM1"
},
"air_filter_pm1_usage_hours": {
"name": "Ore di utilizzo del filtro PM1"
}
},
"switch": {
@@ -1170,15 +1136,6 @@
},
"away_mode": {
"name": "Modalità assenza"
},
"watertank_light": {
"name": "Luce del serbatoio dell'acqua"
},
"uv_led": {
"name": "LED UV"
},
"ventilation_alarm": {
"name": "Allarme ventilazione"
}
},
"time": {
@@ -278,9 +278,9 @@
"name": "Programma",
"state": {
"80": "Delicaat",
"83": "Normaal",
"83": "Express 60",
"84": "Intensief",
"86": "Express 60",
"86": "Normaal",
"90": "Zelfreiniging",
"0e": "AI Wash",
"07": "Voorspoelen",
@@ -325,10 +325,7 @@
"1e": "Outdoor",
"1f": "Gemengde was",
"2b": "Zelf trommel drogen",
"4c": "Opfrissen",
"51": "Eco katoen",
"53": "AI drogen+",
"4e": "Zelf drogen"
"4c": "Opfrissen"
}
},
"favorite_capacity": {
@@ -624,28 +621,6 @@
},
"zone_mode": {
"name": "Zonemodus"
},
"watertank_light_color": {
"name": "Kleur van het waterreservoirlicht",
"state": {
"warmwhite": "Warm wit",
"naturalwhite": "Natuurlijk wit",
"coolwhite": "Koel wit",
"green": "Groen",
"purple": "Paars",
"blue": "Blauw",
"orange": "Oranje",
"pink": "Roze"
}
},
"watertank_light_brightness": {
"name": "Helderheid van het waterreservoirlicht"
},
"air_filter_pm1_threshold": {
"name": "PM1-filteralarmdrempel"
},
"freezer_temperature_setpoint": {
"name": "Temperatuur vriesgedeelte"
}
},
"sensor": {
@@ -994,15 +969,6 @@
},
"zone_temperature": {
"name": "Zonetemperatuur"
},
"watertank_full_alarm_status": {
"name": "Status alarm waterreservoir vol"
},
"air_filter_pm1_usage": {
"name": "PM1-filtergebruik"
},
"air_filter_pm1_usage_hours": {
"name": "PM1-filtergebruik in uren"
}
},
"switch": {
@@ -1170,15 +1136,6 @@
},
"away_mode": {
"name": "Afwezigheidsmodus"
},
"watertank_light": {
"name": "Waterreservoirlicht"
},
"uv_led": {
"name": "Uv-led"
},
"ventilation_alarm": {
"name": "Ventilatiealarm"
}
},
"time": {
@@ -716,5 +716,5 @@
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because, contrary to the assumption that fixture's seed batch was built on, this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), so the Collection-batch fallback this fixture exercises (registry.subdevices.enumerate_subdevices' per-href probe, issue #205) is what has to find the subdevice instead. The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. No other /6c2dff6d-ee5c-dad1-6a5e-000000000001/* href has been confirmed live yet, so none are seeded here; this fixture's expected outcome is the candidate being found by the flat-href probe but then correctly held back by discover_partitioned's liveness gate (information alone binds no entity), matching where the real issue stands -- not a materialized climate entity, which would require guessing at unconfirmed hrefs. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), which is what this fixture exercises: registry.subdevices.enumerate_subdevices' clone-the-master fallback (issue #265, which replaced the per-href probe loop this fixture originally exercised for issue #205). The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. It's kept here as that real confirmation even though the current fallback no longer probes any href beyond /device/0 to decide whether the subdevice exists at all: since issue #265, this fixture's expected outcome is the candidate materializing immediately with the master's own device0 hrefs/values cloned under the prefix as its assumed state -- a full climate entity included -- rather than waiting on per-href confirmation this firmware may never give. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
}
@@ -1,641 +0,0 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "224",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "224",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/filter/airdustPM1filter/vs/0",
"rep": {
"x.com.samsung.da.filterCapacity": "3",
"x.com.samsung.da.filterCapacityUnit": "Month",
"x.com.samsung.da.filterResetType": [
"notresetable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "0.5"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "12",
"x.com.samsung.da.supportedModes": [
"0",
"11",
"12",
"13",
"14",
"15",
"16"
],
"x.com.samsung.da.modesName": [
"Auto",
"1",
"2",
"3",
"4",
"5",
"Max"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": "**REDACTED**"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "27.0",
"x.com.samsung.da.current": "28.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 28.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 27.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "400.000000",
"x.com.samsung.da.instantaneousPower": "14.000000",
"x.com.samsung.da.cumulativePower": "882861",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeDate": "1785798000",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 400.0,
"power": 14.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"ArtificialWorking_Off",
"welcomecare_Off",
"Volume_Mute",
"Operation_Family",
"ComfortAICooling_Off",
"OnTimer_0",
"OffTimer_0",
"Sleep_0",
"Booster_Off",
"OutdoorSilence_Off",
"AiTempChanged_Off",
"AiTemp_250",
"StopAutoClean_Idle",
"Weather_Off",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"InDoorVentil_Off",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"ProtectOperation_Off",
"EnergySaveIcon_Off",
"ExtendOptionCode_16975",
"OptionCode_2589",
"RacInfo_First",
"ModelInfo_16K_BORA_VENT2",
"RacInfo_None_Second",
"OutdoorConnection_Connected",
"DurationOn_0",
"UpdateAllow_NotAllowed",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"causeSource": "SMTS"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_FAC_TIME_23K|10250841|600007110017112BCE00CE2200937000",
"x.com.samsung.da.description": "TP1X_FAC_TIME_23K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AF2",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02517A260330",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02508A23020200,02509A24030701",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02502A23011300,02299A10000300"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "LED",
"x.com.samsung.da.number": "02510A22121900,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "BLERemoteController",
"x.com.samsung.da.number": "22021760"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1138000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"CLEAN_OPTION_SUPPORT",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "71"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"durationminutes": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000000D04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/uvled/vs/0",
"rep": {
"x.com.samsung.da.modes": "On",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-WW-TP1-23-AFXX00",
"versions": [
"10260330"
],
"visVersion": "260330"
},
{
"type": "Micom",
"modelId": "845110250241FFFFFFFF",
"versions": [
"23011300",
"FFFFFFFF"
],
"visVersion": "230113"
},
{
"type": "Micom",
"modelId": "04511025084110250941",
"versions": [
"23020200",
"24030701"
],
"visVersion": "240307"
},
{
"type": "Micom",
"modelId": "04511025024110229941",
"versions": [
"23011300",
"10000300"
],
"visVersion": "230113"
},
{
"type": "Micom",
"modelId": "045110240441FFFFFFFF",
"versions": [
"23121900",
"FFFFFFFF"
],
"visVersion": "231219"
},
{
"type": "Micom",
"modelId": "045110251041FFFFFFFF",
"versions": [
"22121900",
"FFFFFFFF"
],
"visVersion": "221219"
}
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "1",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1DFFFFFFFF121E121EFFFFFFFFFFFF01000000013F0100013F000000001C0C",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/ventilation/setting/vs/0",
"rep": {
"alarm": "Off"
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "Off",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
-426
View File
@@ -1,426 +0,0 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "224",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "224",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"224",
"300",
"500",
"700"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"cleaningTime": "30",
"minCleaningTime": "10",
"maxCleaningTime": "60",
"cleaningTimeUnit": "10",
"x.com.samsung.da.supportedStatus": [
"Start",
"TimedClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"TimedClean",
"Off"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "WaterTankFull_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-03T14:54:15",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "145.000000",
"x.com.samsung.da.cumulativePower": "101100",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Smart",
"Max",
"High",
"Medium",
"Quiet",
"ClothesDrying"
],
"x.com.samsung.da.modes": [
"Quiet"
],
"x.com.samsung.da.options": [
"Volume_Mute",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "1",
"displayCondition": "Enable"
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10283341|77000000001700000A00000000000000",
"x.com.samsung.da.description": "TP1X_DA_AC_DHM_01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AY0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02544A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02833A26010600,FFFFFFFFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02590A10000100,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Touch IC",
"x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_2.0",
"AI_Heat"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "63",
"x.com.samsung.da.desiredHumidity": "60",
"increment": "5",
"x.com.samsung.da.fivepercentHumidity": "64"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "04001E46070A5A00000000000000",
"x.com.samsung.da.id": "DHM",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-06-24",
"otnList": [
{
"type": "WIFI",
"modelId": "ADH-WW-TP1-23-AY7100",
"versions": [
"18260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "042110283341FFFFFFFF",
"versions": [
"26010600",
"FFFFFFFF"
],
"visVersion": "260106"
},
{
"type": "Micom",
"modelId": "042110259041FFFFFFFF",
"versions": [
"10000100",
"FFFFFFFF"
],
"visVersion": "100001"
},
{
"type": "Micom",
"modelId": "042110255241FFFFFFFF",
"versions": [
"23020200",
"FFFFFFFF"
],
"visVersion": "230202"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600",
"x.com.samsung.da.id": "DHM",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/display/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/welcome/humidity/vs/0",
"rep": {
"requestId": "0000",
"operatingStatus": "None"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/watertank/lighting/vs/0",
"rep": {
"status": "On",
"colorOption": "NaturalWhite",
"mode": "High",
"waterfullAlarmStatus": "On",
"colorSupportedList": [
"WarmWhite",
"NaturalWhite",
"CoolWhite",
"Green",
"Purple",
"Blue",
"Orange",
"Pink"
],
"modeSupportedList": [
"High",
"Low"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
-438
View File
@@ -1,438 +0,0 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "211700",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785693600",
"x.com.samsung.da.cumulativeDateUTC": "1785686400",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0165",
"UpdateAllow_NotAllowed",
"Course_16",
"AiOption_On",
"MixedLoadBell_Disable",
"MixedLoadBellNoti_Nothing",
"LaundryOutTime_0",
"SeamlessControl_Enable",
"KidsLockBypass_On",
"WashingTimes_25",
"DrumCleanProposal_25",
"DetergentOnce_1",
"DetergentLeft_0",
"DetergentBase_5",
"DetergentAlarm_Off",
"DetergentType_3",
"DetergentTotal_0",
"SpecialFunction_20",
"AvailableDelayTime_215",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_B214DCB4003C",
"WrinklePreventRunning_Off",
"QuickDrive_Off",
"SendToDevice_Off",
"GMT_04",
"DrumLight_Off",
"DryTimeSet_FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060007FE020007FE020007FE0000000000000000",
"EnergyLevelSet_05030403050302040503050203030402010205",
"MostUsed_16D31E",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0",
"MixedLoadBellSet_0202FF0202FFFFFF02FFFFFFFFFFFFFFFFFF",
"EmptyingTheBucket_Nothing",
"SavingModeCondition_01010202171D0300",
"SavingMode_Off",
"QuickDriveSet_F0000000F000000000000000000000000000",
"GeoFenceAlarm",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2023-12-03T22:07:18|2024-02-09T15:18:59|2024-07-07T23:06:16|2024-10-19T19:29:45|2025-01-26T16:10:23|2025-05-10T19:59:59|2025-07-13T14:58:26|2026-01-09T21:27:43|2026-03-21T18:52:46|2026-05-24T21:38:08",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"116D31E29D31E23D00017D31E18D21E19D20C1DD3181BD3181CD31E21D4101AD1021ED31820D10227D00025D00024D0004CD21C2BD000"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "On"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "03:35:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Drying",
"Cooling",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Drying",
"Cooling",
"Finish"
],
"currentJobState": "None",
"remainingTime": "03:35:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000",
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV9400B/DC92-03267A_2C56",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "DR1",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20317441|30010102001611000AA3031F00000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02986A260118(A182)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20317441221114080FFFFF203267412210245204FFFF(01652031744120326741_30221118)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03174A22111408,03267A22102452",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off",
"x.com.samsung.da.dryLevel": "Normal",
"x.com.samsung.da.supportedDryLevel": [
"None",
"Damp",
"Less",
"Normal",
"More"
],
"x.com.samsung.da.dryTime": "00:00:00",
"x.com.samsung.da.supportedDryTime": [
"00:00:00",
"00:20:00",
"00:30:00",
"00:40:00",
"00:50:00",
"01:00:00",
"01:30:00",
"02:00:00",
"02:30:00",
"03:00:00",
"04:00:00"
],
"x.com.samsung.da.dryerType": "Electricity"
}
},
{
"href": "/st/dryercourse/vs/0",
"rep": {
"x.com.samsung.da.st.dryerMode": "Table_03_Course_16",
"x.com.samsung.da.st.courseTable": "Table_03"
}
},
{
"href": "/setting/vs/0",
"rep": {}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_161B1D29231718191C211A1E202725244C2B",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1618202B"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "true",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
"x.com.samsung.da.modelCode": "M(None),W(DV90BB7445GES7)"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {
"x.com.samsung.da.currentStatus": "None"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-03-04",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_WM_TP1_21_COMMON",
"versions": [
"30260118"
],
"visVersion": "260118"
},
{
"type": "Micom",
"modelId": "01652031744120326741",
"versions": [
"22111408",
"22102452"
],
"visVersion": "221114"
},
{
"type": "Micom",
"modelId": "016520322441FFFFFFFF",
"versions": [
"22050903",
"FFFFFFFF"
],
"visVersion": "220509"
}
]
}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "Off"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "CZ"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Prague",
"offset": "+02:00",
"DST": "ON"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+1 -5
View File
@@ -2,9 +2,6 @@
"state_keys": [
"absence_power_saving_active",
"absence_power_saving_mode",
"air_filter_pm1_status",
"air_filter_pm1_usage",
"air_filter_pm1_usage_hours",
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
@@ -32,7 +29,6 @@
"selfcheck_result",
"selfcheck_status",
"super_fine_dust",
"tropical_night_mode",
"uv_led"
"tropical_night_mode"
]
}
@@ -19,6 +19,26 @@
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"subdevice_6c2dff6dee5cdad16a5e000000000001_air_filter_status",
"subdevice_6c2dff6dee5cdad16a5e000000000001_air_filter_threshold",
"subdevice_6c2dff6dee5cdad16a5e000000000001_air_filter_usage",
"subdevice_6c2dff6dee5cdad16a5e000000000001_air_filter_usage_hours",
"subdevice_6c2dff6dee5cdad16a5e000000000001_alarm_code",
"subdevice_6c2dff6dee5cdad16a5e000000000001_auto_clean",
"subdevice_6c2dff6dee5cdad16a5e000000000001_auto_clean_progress",
"subdevice_6c2dff6dee5cdad16a5e000000000001_auto_clean_running",
"subdevice_6c2dff6dee5cdad16a5e000000000001_beep",
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
"subdevice_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
"subdevice_6c2dff6dee5cdad16a5e000000000001_diagnosis_status",
"subdevice_6c2dff6dee5cdad16a5e000000000001_energy_kwh",
"subdevice_6c2dff6dee5cdad16a5e000000000001_firmware_update",
"subdevice_6c2dff6dee5cdad16a5e000000000001_humidity",
"subdevice_6c2dff6dee5cdad16a5e000000000001_odor_controller_active",
"subdevice_6c2dff6dee5cdad16a5e000000000001_odor_controller_progress",
"subdevice_6c2dff6dee5cdad16a5e000000000001_power_energy_kwh",
"subdevice_6c2dff6dee5cdad16a5e000000000001_power_watts",
"subdevice_6c2dff6dee5cdad16a5e000000000001_tropical_night_mode",
"tropical_night_mode"
]
}
@@ -1,33 +0,0 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"tropical_night_mode",
"uv_led",
"ventilation_alarm"
]
}
-27
View File
@@ -1,27 +0,0 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"child_lock",
"display",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"humidity",
"mute_once",
"operating_mode",
"power_switch",
"power_watts",
"target_humidity",
"watertank_full_alarm_status",
"watertank_light",
"watertank_light_brightness",
"watertank_light_color"
]
}
-28
View File
@@ -1,28 +0,0 @@
{
"state_keys": [
"alarm_code",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"dry_level",
"dry_time",
"dryer_type",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"wrinkle_prevent"
]
}
@@ -1,30 +0,0 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"auto_door_opener",
"brightness_level",
"cabinet_light_dim",
"cabinet_light_switch",
"cooler_temperature_setpoint",
"day_brightness",
"door_alert",
"door_cooler_open",
"door_freezer_open",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"flex_zone_mode",
"freezer_temperature_setpoint",
"fridge_sound",
"night_end",
"night_start",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
-1
View File
@@ -10,7 +10,6 @@
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"dry_level",
"energy_kwh",
"finish_time",
-1
View File
@@ -9,7 +9,6 @@
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"finish_time",
"job_beginning_status",
+1 -2
View File
@@ -11,7 +11,6 @@
"detergent_water_hardness",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"finish_time",
"firmware_update",
@@ -28,4 +27,4 @@
"spin_speed",
"wash_temperature"
]
}
}
-1
View File
@@ -9,7 +9,6 @@
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"finish_time",
"firmware_update",
@@ -1,467 +0,0 @@
{
"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": "/alarms/vs/0",
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/cabinet/light/total/vs/0",
"rep": {
"x.com.samsung.da.lightLevel": "100",
"x.com.samsung.da.lightResolution": "3",
"x.com.samsung.da.lightControl.off.include": "Off",
"x.com.samsung.da.lightControl": "Off",
"x.com.samsung.da.lightControl.hide": "true",
"light.dimming.status": "Off",
"href": "/cabinet/light/total/vs/0",
"rt": [
"x.com.samsung.da.cabinetlight"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/settings/sound/alert/door/vs/0",
"rep": {
"alert.door": "1",
"supportedAlert.door": [
"1",
"2",
"3",
"4"
],
"href": "/settings/sound/alert/door/vs/0",
"rt": [
"x.com.samsung.alert.door"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/cabinet/light/enhanced/vs/0",
"rep": {
"light.control.status": "On",
"level.brightness.daytime": "100",
"level.brightness.nighttime": "66",
"night.starttime": "2026-07-31T15:30:00",
"night.duration.minute": "540",
"timezone.offset": "+05:30",
"href": "/cabinet/light/enhanced/vs/0",
"rt": [
"x.com.samsung.da.light.enhanced"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "",
"x.com.samsung.da.countryCode": "",
"href": "/configuration/vs/0"
}
},
{
"href": "/temperature/definite/freezer/vs/0",
"rep": {
"x.com.samsung.da.definite.desired": "-19",
"x.com.samsung.da.definite.supportedList": [
"-23",
"-21",
"-19",
"-17",
"-15"
],
"x.com.samsung.da.unit": "Celsius",
"href": "/temperature/definite/freezer/vs/0"
}
},
{
"href": "/temperature/definite/cooler/vs/0",
"rep": {
"x.com.samsung.da.definite.desired": "4",
"x.com.samsung.da.definite.supportedList": [
"1",
"2",
"3",
"4",
"7"
],
"x.com.samsung.da.unit": "Celsius",
"href": "/temperature/definite/cooler/vs/0"
}
},
{
"href": "/door/freezer/0",
"rep": {
"openState": "Close",
"href": "/door/freezer/0",
"rt": [
"oic.r.door"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close",
"href": "/door/cooler/0",
"rt": [
"oic.r.door"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door"
},
{
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door"
}
],
"href": "/doors/vs/0"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Not_Supported",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.realSaving": "Off",
"href": "/drlc/vs/0"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "1274",
"x.com.samsung.da.instantaneousPower": "46",
"x.com.samsung.da.cumulativePower": "34106",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"href": "/energy/consumption/vs/0",
"x.com.samsung.da.cumulativeDateUTC": "1785402720"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+05:30",
"x.com.samsung.supprtedtype": 1,
"href": "/file/information/vs/0"
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off",
"href": "/refrigeration/vs/0",
"rt": [
"x.com.samsung.da.fridge"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/energy/ailevel/vs/0",
"rep": {
"aiLevel": "1",
"supportedAiLevel": [
"1",
"2"
],
"href": "/energy/ailevel/vs/0"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_REF_21K|70657341|00060123001811344100000020090400",
"x.com.samsung.da.description": "TP1X_REF_21K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "RT1",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"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": "260617",
"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": "2602020C, FFFFFFFF, 24062000, FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"href": "/information/vs/0"
}
},
{
"href": "/status/lock/vs/0",
"rep": {
"x.com.samsung.da.device.sound": "On",
"x.com.samsung.da.doorAlarmSound": "On",
"href": "/status/lock/vs/0",
"rt": [
"x.com.samsung.da.lockstatus"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"RVACATION_OFF",
"FCONVERT_FREEZER"
],
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"VACATION_MODE_CONTROL"
],
"x.com.samsung.da.supportedOptions": [
"FCONVERT_FREEZER",
"FCONVERT_FRIDGE",
"FCONVERT_OFF"
],
"href": "/mode/vs/0",
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true",
"href": "/realtimenotiforclient/vs/0"
}
},
{
"href": "/runningmode/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0,
"href": "/runningmode/vs/0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start"
],
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.result": "Success",
"x.com.samsung.da.error": [
"ErrorCode_None"
],
"href": "/selfcheck/vs/0"
}
},
{
"href": "/rm/control/vs/0",
"rep": {
"minPeriod": "9000",
"href": "/rm/control/vs/0"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Refrigerator",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "A-RFWW-TP1-24-T4-COM",
"versions": [
"20260617"
],
"visVersion": "260617"
},
{
"type": "Micom",
"modelId": "835070657341FFFFFFFF",
"versions": [
"2602020C",
"FFFFFFFF"
],
"visVersion": "260202"
},
{
"type": "Micom",
"modelId": "035070657341FFFFFFFF",
"versions": [
"2602020C",
"FFFFFFFF"
],
"visVersion": "260202"
},
{
"type": "Micom",
"modelId": "035070658641FFFFFFFF",
"versions": [
"24062000",
"FFFFFFFF"
],
"visVersion": "240620"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Kolkata",
"offset": "+05:30",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "IoT0"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "UnEnrolled",
"devicestate": "Deactivated",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+8 -12
View File
@@ -6,18 +6,12 @@ token -- this board was the one exception (its oneUiVersion self-reports
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
table), so it silently fell back to common caps and lost its climate entity.
This dump is NOT fully covered yet -- eight hrefs remain unbound (edge
lighting, a second stateful light resource, absence-clean, four
`/settings/sound/*` resources, smart-sensing-cooling), all genuinely new to
this board generation. That's a real device-support gap, left documented
here rather than guessed at, per the 'don't guess' rule -- fixing the
routing regression was the scope of #191.
/uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too,
until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both --
this board's own live filterUsage/filterStatus data on the PM1 filter binds
through the same exists_fn-gated entities #270's dump (which has neither
field) leaves empty.
This dump is NOT fully covered yet -- ten hrefs remain unbound (edge
lighting, PM1 filter, a second stateful light resource, absence-clean, four
`/settings/sound/*` resources, smart-sensing-cooling, UV LED), all genuinely
new to this board generation. That's a real device-support gap, left
documented here rather than guessed at, per the 'don't guess' rule -- fixing
the routing regression was the scope of #191.
"""
from custom_components.localthings.registry.adapter import flatten
@@ -28,6 +22,7 @@ from tests.conftest import _load_device
_STILL_UNBOUND = frozenset(
{
"/edgelighting/vs/0",
"/filter/airdustPM1filter/vs/0",
"/light/stateful/vs/0",
"/mds/absenceclean/vs/0",
"/settings/sound/mode/vs/0",
@@ -35,6 +30,7 @@ _STILL_UNBOUND = frozenset(
"/settings/sound/output/vs/0",
"/settings/sound/volume/vs/0",
"/smartsensingcooling/vs/0",
"/uvled/vs/0",
}
)
@@ -1,83 +0,0 @@
"""Tests for the TP1X_FAC_TIME_23K air conditioner (issue #270).
A 2-in-1 system (x.com.samsung.da.numofsubdevice='2') whose UUID-prefixed
sibling materializes via the flat fallback (issue #205) -- unrelated to the
three capability gaps this fixture actually locks in (UV LED, ventilation
alarm, and the unmodeled PM1 filter), which live entirely on canonical
hrefs already exercised by the master's own resources.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SwitchDesc
from tests.conftest import _load_device
def _airconditioner():
resources = _load_device("airconditioner_tp1x_fac_time_23k")
info = resources["/information/vs/0"]
reg = for_device_by_model(
info["x.com.samsung.da.modelNum"],
info["x.com.samsung.da.description"],
)
return reg, resources
def _bound():
reg, resources = _airconditioner()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_model_resolves_to_airconditioner_registry():
reg, _ = _airconditioner()
assert reg is not None and reg.name == "airconditioner"
def test_no_unbound_hrefs():
reg, resources = _airconditioner()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_uv_led_switch_reads_on_and_has_write_contract():
state = _state()
assert state["uv_led"] is True
desc = _desc("uv_led")
assert isinstance(desc, SwitchDesc)
path, body = desc.write_fn("Off", {})
assert path == ["uvled", "vs", "0"]
assert body == {"x.com.samsung.da.modes": "Off"}
def test_ventilation_alarm_switch_reads_off_and_has_write_contract():
state = _state()
assert state["ventilation_alarm"] is False
desc = _desc("ventilation_alarm")
assert isinstance(desc, SwitchDesc)
path, body = desc.write_fn("On", {})
assert path == ["ventilation", "setting", "vs", "0"]
assert body == {"alarm": "On"}
def test_pm1_filter_has_no_live_fields_so_no_state_is_reported():
"""This dump's /filter/airdustPM1filter/vs/0 has only
filterCapacity/filterCapacityUnit/filterResetType -- no filterUsage or
filterStatus -- so every AIR_FILTER_PM1 entity's exists_fn gates it out.
The href still covers (see test_no_unbound_hrefs above) without
fabricating a reading from nothing (the 'don't guess' rule); contrast
with test_airconditioner_cac.py, whose dump has live data on the same
href and does get real air_filter_pm1_* state."""
state = _state()
assert not any(k.startswith("air_filter_pm1_") for k in state)
-80
View File
@@ -1,80 +0,0 @@
"""Tests for issue #235: HOMECARE_WIZARD_V2 false-positive unmapped-mode
warning, and the entity_id-is-None logging gap it surfaced.
Uses the pre-existing airconditioner_tp2x_rac_20k fixture (issue #37) --
its /mode/vs/0 already carries the exact reporter-confirmed shape:
supportedModes includes 'HOMECARE_WIZARD_V2' alongside the real modes, the
current mode ('Cool') never is it, and /configuration/vs/0's
airconOptionList lists it alongside other plain capability flags.
"""
from __future__ import annotations
import logging
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.core import HomeAssistant
from custom_components.localthings.climate import _NON_HVAC_OPTION_CODES, LocalThingsClimate
from custom_components.localthings.registry.entities import ClimateDesc
from tests.test_subdevice_discovery import _coordinator, _discover
@pytest.fixture
async def climate(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_tp2x_rac_20k")
bound = next(b for b in coordinator.bound if isinstance(b.desc, ClimateDesc))
return LocalThingsClimate(coordinator, bound)
def test_homecare_wizard_v2_excluded_from_hvac_modes(climate):
"""supportedModes is ['Auto', 'Cool', 'Dry', 'Wind', 'Heat',
'HOMECARE_WIZARD_V2'] -- five real modes plus the option flag. Every
real mode maps through; the flag contributes no extra (or bogus)
HVACMode entry."""
modes = climate.hvac_modes
assert set(modes) == {
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.DRY,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
}
def test_homecare_wizard_v2_reports_no_warning(climate, caplog):
"""Reading hvac_modes must not emit the issue #93 unmapped-mode warning
for HOMECARE_WIZARD_V2 -- that's the actual bug report: every owner of
an affected unit saw this fire on every start."""
with caplog.at_level(logging.WARNING):
_ = climate.hvac_modes
assert "HOMECARE_WIZARD_V2" not in caplog.text
def test_hvac_mode_is_off_when_powered_off(climate):
"""The fixture's power is off -- hvac_mode short-circuits to OFF before
ever reading /mode/vs/0's 'Cool', matching real behavior (mode is only
meaningful while the unit is on)."""
assert climate.hvac_mode == HVACMode.OFF
def test_homecare_wizard_v2_is_the_known_non_hvac_set():
assert "HOMECARE_WIZARD_V2" in _NON_HVAC_OPTION_CODES
def test_warn_unmapped_falls_back_to_unique_id_when_entity_id_unset(climate, caplog):
"""Issue #235's secondary bug: _warn_unmapped logged 'None: device mode
...' because entity_id is unset until the entity is added to hass --
exactly the state a fixture-backed entity is in here, without ever
calling async_add_entities. unique_id is set eagerly in __init__
(entity.py), so it must appear in the log line instead of a bare
'None'."""
assert climate.entity_id is None
assert climate.unique_id is not None
with caplog.at_level(logging.WARNING):
climate._warn_unmapped("/mode/vs/0", "TotallyUnknownCode")
assert "None:" not in caplog.text
assert climate.unique_id in caplog.text
@@ -1,94 +0,0 @@
"""Tests for the TP1X_DA_AC_DHM_01001_0000 dehumidifier revision
(issues #271/#231, model AY70H18100GTD).
Both issues submitted the identical dump: same board, same two previously
unbound hrefs (/display/vs/0, /watertank/lighting/vs/0). Confirms both the
new capabilities and the /oic/d device-type routing added alongside them.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import (
for_device_by_model,
for_device_by_oic_type,
)
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import SelectDesc, SwitchDesc
from tests.conftest import _load_device
def _dehumidifier():
resources = _load_device("dehumidifier_tp1x_dhm01001")
info = resources["/information/vs/0"]
reg = for_device_by_model(
info["x.com.samsung.da.modelNum"],
info["x.com.samsung.da.description"],
)
return reg, resources
def _bound():
reg, resources = _dehumidifier()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_model_resolves_to_dehumidifier_registry():
reg, _ = _dehumidifier()
assert reg is not None and reg.name == "dehumidifier"
def test_oic_device_type_resolves_to_dehumidifier_registry():
reg = for_device_by_oic_type(("oic.wk.d", "x.com.st.d.dehumidifier"))
assert reg is not None and reg.name == "dehumidifier"
def test_no_unbound_hrefs():
reg, resources = _dehumidifier()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_display_switch_present_and_reads_on():
state = _state()
assert state["display"] is True
def test_watertank_light_switch_write_contract():
desc = _desc("watertank_light")
assert isinstance(desc, SwitchDesc)
path, body = desc.write_fn("On", {})
assert path == ["watertank", "lighting", "vs", "0"]
assert body == {"status": "On"}
def test_watertank_light_color_options_come_from_live_supported_list():
desc = _desc("watertank_light_color")
assert isinstance(desc, SelectDesc)
assert desc.options_field == "colorSupportedList"
path, body = desc.write_fn("Green", {})
assert path == ["watertank", "lighting", "vs", "0"]
assert body == {"colorOption": "Green"}
def test_watertank_light_brightness_options_come_from_live_supported_list():
desc = _desc("watertank_light_brightness")
assert isinstance(desc, SelectDesc)
assert desc.options_field == "modeSupportedList"
path, body = desc.write_fn("Low", {})
assert path == ["watertank", "lighting", "vs", "0"]
assert body == {"mode": "Low"}
def test_watertank_full_alarm_status_exposed_read_only():
state = _state()
assert state["watertank_full_alarm_status"] == "On"
+12 -10
View File
@@ -111,24 +111,26 @@ async def test_diagnostics_reports_prefixed_subdevice(
assert diag["subdevices_skipped"] == []
async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate(
async def test_diagnostics_reports_flat_hrefs_for_materialized_prefixed_candidate(
hass: HomeAssistant,
enable_custom_integrations,
) -> None:
"""issue #205: a prefixed candidate found through the per-href flat
"""issue #205: a prefixed candidate found through the clone-the-master
fallback (no working /<uuid>/device/0 Collection) has no meaningful
seed_path -- diagnostics reports None there instead of the misleading
bare "/" an empty tuple would otherwise join to, and lists the actual
hrefs the fallback confirmed instead."""
bare "/" an empty tuple would otherwise join to, and lists the full set
of hrefs the fallback assumed (issue #265 -- every href the master
itself has, not just a confirmed few) instead."""
coordinator = _coordinator(hass)
await _discover(coordinator, "airconditioner_fac_bora_205_flat")
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
assert diag["subdevices"] == []
assert len(diag["subdevices_skipped"]) == 1
skipped = diag["subdevices_skipped"][0]
assert skipped["kind"] == "prefixed"
assert skipped["seed_path"] is None
assert skipped["flat_hrefs"] == ["/information/vs/0"]
assert diag["subdevices_skipped"] == []
assert len(diag["subdevices"]) == 1
subdevice = diag["subdevices"][0]
assert subdevice["kind"] == "prefixed"
assert subdevice["seed_path"] is None
assert subdevice["flat_hrefs"] == sorted(diag["resources"])
assert subdevice["bound_entity_count"] > 0
-87
View File
@@ -1,87 +0,0 @@
"""Tests for dryer Drum Clean+ maintenance tracking (issue #258).
Same DrumCleanProposal_/WashingTimes_/DrumCleanLog_ options[] tokens as
washer.py's issue #9 feature, shared via laundry.drum_clean_cycles_remaining/
drum_clean_last_cleaned -- except DrumCleanLog_ on a dryer is a '|'-joined
history of every past clean rather than one bare timestamp.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities.laundry import (
drum_clean_cycles_remaining,
drum_clean_last_cleaned,
)
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _dryer(fixture_name):
resources = _load_device(fixture_name)
info = resources["/information/vs/0"]
reg = for_device_by_model(
info["x.com.samsung.da.modelNum"],
info["x.com.samsung.da.description"],
)
return reg, resources
def _state(fixture_name):
reg, resources = _dryer(fixture_name)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_no_unbound_hrefs():
reg, resources = _dryer("dryer_tp1_21_drum_clean")
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_confirmed_dump_reports_cycles_remaining_and_last_cleaned():
"""DA_WM_TP1_21_COMMON/DV9400B reports DrumCleanProposal_25/
WashingTimes_25 (0 cycles remaining -- due now) and a ten-entry
'|'-joined DrumCleanLog_, most recent 2026-05-24T21:38:08."""
state = _state("dryer_tp1_21_drum_clean")
assert state["drum_clean_cycles_remaining"] == 0
assert str(state["drum_clean_last_cleaned"]) == "2026-05-24 21:38:08+00:00"
def test_dump_with_no_drum_clean_data_reports_neither_entity():
"""The default dryer fixture only has 'DrumCleanLog_Empty', no
DrumCleanProposal_/WashingTimes_ tokens at all -- both entities stay
gated off rather than reporting a fabricated 0 or an unparseable date."""
state = _state("dryer")
assert "drum_clean_cycles_remaining" not in state
assert "drum_clean_last_cleaned" not in state
def test_multi_entry_drum_clean_log_takes_the_last_timestamp():
rep = {
"x.com.samsung.da.options": [
"DrumCleanLog_2023-12-03T22:07:18|2024-02-09T15:18:59|2026-05-24T21:38:08",
]
}
assert str(drum_clean_last_cleaned(rep)) == "2026-05-24 21:38:08+00:00"
def test_single_entry_drum_clean_log_matches_washer_shape():
"""A bare, no-'|' value (the washer's own shape) parses identically."""
rep = {"x.com.samsung.da.options": ["DrumCleanLog_2024-02-09T15:18:59"]}
assert str(drum_clean_last_cleaned(rep)) == "2024-02-09 15:18:59+00:00"
def test_empty_drum_clean_log_reports_none():
rep = {"x.com.samsung.da.options": ["DrumCleanLog_Empty"]}
assert drum_clean_last_cleaned(rep) is None
def test_cycles_remaining_never_negative():
rep = {
"x.com.samsung.da.options": [
"DrumCleanProposal_25",
"WashingTimes_40",
]
}
assert drum_clean_cycles_remaining(rep) == 0
-50
View File
@@ -329,56 +329,6 @@ class TestDefiniteTemperatureCooler:
assert rep["x.com.samsung.da.definite.supportedList"] == ["1", "2", "3", "4", "7"]
class TestDefiniteTemperatureFreezer:
"""Discrete freezer setpoint (TP1X_REF_21K, issue #229) -- the same
definite-setpoint pattern as DEFINITE_TEMPERATURE_COOLER above, on a
fridge/freezer combo that reports it for *both* compartments. Identical
field shape, just negative supportedList values."""
def test_href(self):
assert fridge.DEFINITE_TEMPERATURE_FREEZER.href == "/temperature/definite/freezer/vs/0"
def test_write(self):
desc = next(
e for e in fridge.DEFINITE_TEMPERATURE_FREEZER.entities if isinstance(e, SelectDesc)
)
assert desc.write_fn is not None
result = desc.write_fn("-19", {})
assert result is not None
path, body = result
assert path == ["temperature", "definite", "freezer", "vs", "0"]
assert body == {"x.com.samsung.da.definite.desired": "-19"}
def test_no_unbound_hrefs_and_discrete_options(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device("refrigerator_tp1x_ref_21k_definite")
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state["cooler_temperature_setpoint"] == "4"
assert state["freezer_temperature_setpoint"] == "-19"
rep = resources["/temperature/definite/freezer/vs/0"]
assert rep["x.com.samsung.da.definite.supportedList"] == [
"-23",
"-21",
"-19",
"-17",
"-15",
]
class TestKimchiZone:
"""Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) --
top/middle/bottom each report an identically-shaped currentMode/
+14 -103
View File
@@ -72,12 +72,7 @@ def test_registry_reproduces_golden_state_keys_for_ehs():
def test_registry_reproduces_golden_state_keys_for_washer_wa8000t():
"""Top-load washer (WA8000T, issue #106) reports no oneUiVersion and
used the 'WA' consumer-model prefix, previously unmapped in
_CONSUMER_PREFIX_TO_KEY -- fell back to 'unknown'.
Also gains drum_clean_last_cleaned (issue #258): this fixture's
DrumCleanLog_ is a '|'-joined multi-entry history, which always failed
datetime.fromisoformat and silently returned None before
laundry.drum_clean_last_cleaned learned to take the last entry."""
_CONSUMER_PREFIX_TO_KEY -- fell back to 'unknown'."""
from tests.conftest import _load_device
resources = _load_device("washer_wa8000t")
@@ -275,10 +270,7 @@ def test_registry_reproduces_golden_state_keys_for_range_hood():
def test_registry_reproduces_golden_state_keys_for_washer_flexwash():
"""FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW)
report no oneUiVersion and previously fell through for_device_by_model's
consumer-prefix map entirely -- issue #19.
Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test
above for why."""
consumer-prefix map entirely -- issue #19."""
from tests.conftest import _load_device
resources = _load_device("washer_flexwash")
@@ -293,10 +285,7 @@ def test_registry_reproduces_golden_state_keys_for_washer_flexwash():
def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, with no separate dryer resource -- issue #22.
Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test
above for why."""
/washer/vs/0 itself, with no separate dryer resource -- issue #22."""
from tests.conftest import _load_device
resources = _load_device("washer_dryer_combo")
@@ -633,10 +622,7 @@ def test_registry_reproduces_golden_state_keys_for_washer_wa55a7700av():
-- a different board generation than the WA8000T's TP2_20_COMMON,
reached through the same 'WA' consumer-model-prefix fallback (issue
#106). Binds cleanly against the existing washer registry with zero
unbound hrefs.
Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test
above for why."""
unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device("washer_wa55a7700av")
@@ -1118,18 +1104,16 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1(
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_205_flat():
"""The same reporter's same physical TP2X_FAC_BORA_21K unit as the _2in1
fixture above, but a later capture (issue #205) where /<uuid>/device/0 doesn't
answer -- contrary to what that fixture's own seed batch assumed the
Collection endpoint would do. device0/oic_res are real; the only
UUID-prefixed data is the one href ever actually confirmed live
(/information/vs/0, same real capture the _2in1 fixture uses), fed
through registry.subdevices.enumerate_subdevices' per-href flat
fallback instead of a Collection batch. /information/vs/0 alone binds
no entity, so the candidate is found but never materializes -- this
golden has no `subdevice_...`-prefixed keys at all, same shape as
tests/fixtures/golden/airconditioner_fac_bora.json, which is the point:
a device whose sibling can't yet be confirmed live must regress to
exactly the master-only state, never a phantom or partial subdevice."""
fixture above, but a later capture (issue #205) where /<uuid>/device/0
doesn't answer -- contrary to what that fixture's own seed batch assumed
the Collection endpoint would do. device0/oic_res are real; there is no
UUID-prefixed seed data at all, which is the point of this fixture:
issue #265 replaced the old per-href confirmation fallback with an
unconditional clone of the master's own hrefs/values under the prefix,
so this golden's `subdevice_...`-prefixed keys are now the *same set* as
the master's own unprefixed keys -- every entity the master has, the
assumed sibling gets too, not just the handful the _2in1 fixture's
synthetic partial seed happened to confirm."""
name = "airconditioner_fac_bora_205_flat"
golden = json.loads((GOLDEN / f"{name}.json").read_text())
state_keys = _new_subdevice_aware_state_keys(name)
@@ -1240,79 +1224,6 @@ def test_registry_reproduces_golden_state_keys_for_range_ne6516a():
)
def test_registry_reproduces_golden_state_keys_for_dehumidifier_tp1x_dhm01001():
"""TP1X_DA_AC_DHM_01001_0000 revision (issues #271/#231) additionally
reports /display/vs/0 (air_purifier.DISPLAY's exact shape, reused as-is)
and /watertank/lighting/vs/0 (new WATERTANK_LIGHTING capability), both
unbound on the original issue #88 dump."""
from tests.conftest import _load_device
resources = _load_device("dehumidifier_tp1x_dhm01001")
golden = json.loads((GOLDEN / "dehumidifier_tp1x_dhm01001.json").read_text())
state_keys = _new_state_keys("dehumidifier_tp1x_dhm01001", 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_airconditioner_tp1x_fac_time_23k():
"""TP1X_FAC_TIME_23K (issue #270) reports a UV-C LED, a ventilation-alarm
toggle, and a second (PM1-rated, no live usage field) dust filter, none
previously modeled. Also a 2-in-1 system on this dump (numofsubdevice=2,
a UUID-prefixed sibling materializes via the flat fallback) -- the three
new/ignored hrefs are fixed on their canonical form so the sibling picks
them up too, per the adding-device-support skill's 'never index or
prefix a registry href' rule."""
from tests.conftest import _load_device
resources = _load_device("airconditioner_tp1x_fac_time_23k")
golden = json.loads((GOLDEN / "airconditioner_tp1x_fac_time_23k.json").read_text())
state_keys = _new_state_keys("airconditioner_tp1x_fac_time_23k", 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_dryer_tp1_21_drum_clean():
"""DA_WM_TP1_21_COMMON/DV9400B (issue #258) is the first dryer dump with
live DrumCleanProposal_/WashingTimes_ tokens (dryer's default fixture
only ever had 'DrumCleanLog_Empty', same as the parallel washer capability
started from) -- locks in the new drum_clean_cycles_remaining/
drum_clean_last_cleaned entities, including the dryer-specific
'|'-joined multi-timestamp DrumCleanLog_ shape."""
from tests.conftest import _load_device
resources = _load_device("dryer_tp1_21_drum_clean")
golden = json.loads((GOLDEN / "dryer_tp1_21_drum_clean.json").read_text())
state_keys = _new_state_keys("dryer_tp1_21_drum_clean", 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_refrigerator_tp1x_ref_21k_definite():
"""TP1X_REF_21K (issue #229) reports the discrete definite-setpoint
pattern (issue #186) on *both* compartments -- previously only the
cooler half was modeled; DEFINITE_TEMPERATURE_FREEZER covers the
freezer's identical-shape /temperature/definite/freezer/vs/0."""
from tests.conftest import _load_device
resources = _load_device("refrigerator_tp1x_ref_21k_definite")
golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_definite.json").read_text())
state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_definite", 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_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
+65 -65
View File
@@ -273,35 +273,54 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
# ---------------------------------------------------------------------------
# Same reporter and physical unit/UUID again -- issue #205, but this
# time /<uuid>/device/0 doesn't answer. Exercises enumerate_subdevices'
# per-href flat-probe fallback (registry/subdevices.py) against a real
# capture instead of the synthetic sessions test_subdevices.py uses.
# clone-the-master fallback (registry/subdevices.py, issue #265) against a
# real capture instead of the synthetic sessions test_subdevices.py uses.
# ---------------------------------------------------------------------------
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
async def test_fac_bora_205_flat_fallback_clones_master_and_materializes(
hass: HomeAssistant,
):
"""The fixture's only seeded UUID-prefixed href is /information/vs/0 --
the one href ever actually confirmed live under this prefix (issue #177
comment 5113518087) -- since nothing else has been confirmed yet for
this unit. That's enough for the flat-probe fallback to find a
candidate, but /information/vs/0 binds no entity on its own (it's only
ever read for device-type resolution, never bound as a capability), so
discover_partitioned's liveness gate correctly holds it back rather than
materializing a phantom climate card from unconfirmed hrefs. This is the
honest current state of issue #205, not a guess at its resolution."""
"""/<uuid>/device/0 still doesn't answer for this reporter's unit
(issue #205), but issue #265 replaced the old per-href confirmation loop
-- which used to hold this candidate back with only /information/vs/0
ever confirmed live under the prefix -- with an unconditional clone of
the master's own hrefs and values under the prefix, plus one bounded
retry of /information/vs/0 itself. So the sibling now materializes
immediately with the master's own climate state as its assumed starting
point, but its own real, distinct model/serial (this test used to
assert the opposite: that the liveness gate correctly held the
candidate back)."""
coordinator = _coordinator(hass)
resources, _oic_res, _seeds = _load_device_full("airconditioner_fac_bora_205_flat")
await _discover(coordinator, "airconditioner_fac_bora_205_flat")
assert coordinator.subdevices == []
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID]
skipped = coordinator._skipped_subdevices[0].subdevice
assert skipped.kind == "prefixed"
assert skipped.seed_path == ()
assert skipped.flat_hrefs == ("/information/vs/0",)
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
assert coordinator._skipped_subdevices == []
subdevice = coordinator.subdevices[0]
assert subdevice.kind == "prefixed"
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs == tuple(sorted(resources))
# The seed Collection and the one bounded /information/vs/0 retry were
# probed over the network -- nothing else, no per-href confirmation loop.
assert coordinator._subdevice_probes[f"/{_SUB_UUID}/device/0"] is False
assert coordinator._subdevice_probes[f"/{_SUB_UUID}/information/vs/0"] is True
allowed = {f"/{_SUB_UUID}/device/0", f"/{_SUB_UUID}/information/vs/0"}
assert not any(
href.startswith(f"/{_SUB_UUID}/") and href not in allowed
for href in coordinator._subdevice_probes
)
assert _climate_bound(coordinator, _SUB_UUID) is not None
# The confirmed /information/vs/0 reply -- not the master's cloned one --
# is what device_info_for() reads, so the sibling's HA device shows its
# own real model/serial rather than looking like a duplicate of the
# master (issue #177 comment 5113518087's real hand-read capture).
info = coordinator.device_info_for(subdevice)
assert info["model"] == "TP2X_FAC_BORA_RAC_21K"
assert info["model"] != coordinator.device_info.get("model")
# Confirms the master itself is completely unaffected by its sibling's
# Collection endpoint not answering -- same guarantee every other
@@ -310,14 +329,12 @@ async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back
async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssistant):
"""Synthetic (not a real capture, unlike the fixture-driven test above) --
exercises the one path nothing else covers: a flat-mode prefixed
subdevice with *enough* confirmed hrefs to actually pass
discover_partitioned's liveness gate and materialize a real climate
entity, then a subsequent _poll_subdevice_seed re-poll refreshing its
state all the way through to canonical_resources -- the path a real
resolution of issue #205 (once more hrefs are confirmed live for some
unit) would actually need.
"""Synthetic seeds (not a real capture, unlike the fixture-driven test
above) -- exercises the one path nothing else covers: a flat-mode
prefixed subdevice materializing off a clone of the master's own state
(issue #265) and then a subsequent _poll_subdevice_seed re-poll
refreshing its state all the way through to canonical_resources as real
per-href confirmation arrives.
Also pins _poll_subdevice_flat_hrefs' hot/warm skip: climate-critical
hrefs (power/mode/temperature) land on the warm tier by discovery's own
@@ -327,62 +344,45 @@ async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssi
/option/autoclean/vs/0 is cold-tier and is what actually needs this
path."""
resources, oic_res, _real_seeds = _load_device_full("airconditioner_fac_bora_2in1")
seeds = {
# No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback,
# same as the real issue #205 capture above, but this time with
# enough hrefs answering to actually materialize. power/mode/
# temperature values copied verbatim from that fixture's own (real)
# Collection-batch seed, just served individually instead of
# batched, to isolate "does flat mode produce the same result as
# Collection mode" as the only variable.
f"/{_SUB_UUID}/power/vs/0": {"x.com.samsung.da.power": "On"},
f"/{_SUB_UUID}/mode/vs/0": {
"x.com.samsung.da.supportedModes": ["Cool", "Dry", "Wind", "Auto"],
"x.com.samsung.da.modes": ["Cool"],
"x.com.samsung.da.options": [],
},
f"/{_SUB_UUID}/temperature/current/0": {
"range": [18.0, 30.0],
"units": "C",
"temperature": 26.0,
},
f"/{_SUB_UUID}/temperature/desired/0": {
"range": [18.0, 30.0],
"units": "C",
"temperature": 24.0,
},
# Cold-tier -- not covered by _run_subpolls, so this is the href
# that actually depends on _poll_subdevice_flat_hrefs to ever
# refresh at all.
f"/{_SUB_UUID}/option/autoclean/vs/0": {
"x.com.samsung.da.settingStatus": "Off",
},
}
# No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback, same
# as the real issue #205 capture above. No per-href seeds are needed for
# materialization itself anymore: the fallback clones the master's own
# `resources` (real climate state included) under the prefix, with no
# RETRIEVEs at all.
coordinator = _coordinator(hass)
await _discover_with(coordinator, resources, oic_res, seeds)
await _discover_with(coordinator, resources, oic_res, seeds={})
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
subdevice = coordinator.subdevices[0]
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs != ()
assert subdevice.flat_hrefs == tuple(sorted(resources))
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert sub_climate is not None
# Cloned initial state mirrors the master's own values verbatim.
initial_temp = resources["/temperature/current/0"]["temperature"]
initial_autoclean = resources["/option/autoclean/vs/0"]["x.com.samsung.da.settingStatus"]
res = coordinator.canonical_resources(subdevice)
assert res["/temperature/current/0"]["temperature"] == initial_temp
assert res["/option/autoclean/vs/0"]["x.com.samsung.da.settingStatus"] == initial_autoclean
# Re-poll: a fresh reading under the prefix should reach
# canonical_resources through _poll_subdevice_seed's flat-mode branch,
# not just sit frozen at the one-time enumeration snapshot.
# correcting the clone toward the sibling's real, confirmed value --
# not just sitting frozen at the one-time enumeration snapshot.
# FakeCoapSession's `seeds` is typed `dict[str, list]` for the common
# batch-list shape, but (per its own docstring) also legitimately holds
# plain Property maps for probe-style hrefs like these two.
fresh_autoclean = "Off" if initial_autoclean != "Off" else "On"
seeds_map = cast("dict[str, Any]", cast(FakeCoapSession, coordinator._session).seeds)
seeds_map[f"/{_SUB_UUID}/temperature/current/0"] = {
"range": [18.0, 30.0],
"units": "C",
"temperature": 27.5,
"temperature": initial_temp + 1.5,
}
seeds_map[f"/{_SUB_UUID}/option/autoclean/vs/0"] = {
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.settingStatus": fresh_autoclean,
}
refreshed = coordinator._poll_subdevice_seed(subdevice)
@@ -391,16 +391,16 @@ async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssi
# through this path.
assert f"/{_SUB_UUID}/temperature/current/0" not in refreshed
assert refreshed == {
f"/{_SUB_UUID}/option/autoclean/vs/0": {"x.com.samsung.da.settingStatus": "On"},
f"/{_SUB_UUID}/option/autoclean/vs/0": {"x.com.samsung.da.settingStatus": fresh_autoclean},
}
for href, rep in refreshed.items():
coordinator._observe.apply(href, rep, source="poll")
res = coordinator.canonical_resources(subdevice)
assert res["/option/autoclean/vs/0"]["x.com.samsung.da.settingStatus"] == "On"
assert res["/option/autoclean/vs/0"]["x.com.samsung.da.settingStatus"] == fresh_autoclean
# Confirms the skip is about redundant re-fetching, not stale data --
# the warm-tier value from initial discovery is still there, untouched.
assert res["/temperature/current/0"]["temperature"] == 26.0
assert res["/temperature/current/0"]["temperature"] == initial_temp
class _FakeCollectionSession:
+73 -46
View File
@@ -295,75 +295,103 @@ def test_enumerate_prefixed_from_subdevice_id_list():
assert extra == {f"/{_UUID}/mode/vs/0": {"m": "cool"}}
def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty():
def test_enumerate_prefixed_falls_back_to_cloning_master_state_when_device0_collection_is_empty():
"""issue #205: not every prefixed subdevice exposes its own
/<uuid>/device/0 Collection -- not even TP2X_FAC_BORA_21K, the board
this pattern was built against, always does. When it doesn't,
enumeration probes every href the master itself answered this cycle,
individually, under the UUID prefix, and keeps whichever ones answer."""
this pattern was built against, always does. issue #265: probing every
master href individually under the prefix to confirm one used to be the
fallback, but a firmware that drops packets instead of 4.04ing turned
that into a ~300s hang that took the whole config entry's setup down
with it. So the fallback no longer loops over every master href -- it
trusts the device's own subdeviceIdList claim and clones the master's
current hrefs and values verbatim under the prefix, plus one bounded
retry of /information/vs/0 specifically (see the dedicated test below);
that retry also fails to answer here, so the clone is all this
candidate ends up with."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/mode/vs/0": {"m": "cool"},
"/power/vs/0": {"p": "On"},
}
sess = _FakeSession(
{
# (_UUID, 'device', '0') deliberately absent -> Collection probe fails.
(_UUID, "mode", "vs", "0"): {"mode": "cool"},
# (_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
}
)
sess = _FakeSession({}) # (_UUID, 'device', '0') absent -> Collection probe fails.
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert len(subdevices) == 1
subdevice = subdevices[0]
assert (subdevice.kind, subdevice.key) == ("prefixed", _UUID)
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs == ("/mode/vs/0",)
assert extra == {f"/{_UUID}/mode/vs/0": {"mode": "cool"}}
def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers():
"""Same posture as every other candidate check in this module: nothing
answering means no candidate, not a crash."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/mode/vs/0": {"m": "cool"},
assert subdevice.flat_hrefs == ("/mode/vs/0", "/power/vs/0", "/subdevices/vs/0")
assert extra == {
f"/{_UUID}/mode/vs/0": {"m": "cool"},
f"/{_UUID}/power/vs/0": {"p": "On"},
f"/{_UUID}/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
def test_enumerate_prefixed_flat_fallback_confirms_its_own_information():
"""When the one bounded retry of /<uuid>/information/vs/0 *does* answer,
its real reply overrides the master's cloned /information/vs/0 -- so the
sibling's device_info shows its own model/serial instead of the
master's, which would otherwise read as a duplicate device in the UI
even though the two devices' registry identifiers are genuinely
distinct (see device_info_for)."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/information/vs/0": {"x.com.samsung.da.modelNum": "MASTER_MODEL"},
"/mode/vs/0": {"m": "cool"},
}
sess = _FakeSession(
{
(_UUID, "mode", "vs", "0"): {"mode": "cool"},
# (_UUID, 'device', '0') absent -> Collection probe fails.
(_UUID, "information", "vs", "0"): {
"x.com.samsung.da.modelNum": "SIBLING_MODEL",
"x.com.samsung.da.serialNum": "SIBLING-SERIAL",
},
}
)
probes: dict[str, bool] = {}
enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__)
assert probes[f"/{_UUID}/device/0"] is False
assert probes[f"/{_UUID}/mode/vs/0"] is True
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
"""The master itself having nothing but /subdevices/vs/0 in its own
resources this cycle (e.g. a very first, mostly-empty poll) must not
crash the fallback loop -- the only href in `resources` is
/subdevices/vs/0 itself, which the fake session doesn't answer under
the prefix either, so nothing materializes."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert len(subdevices) == 1
subdevice = subdevices[0]
assert subdevice.flat_hrefs == ("/information/vs/0", "/mode/vs/0", "/subdevices/vs/0")
assert extra[f"/{_UUID}/information/vs/0"] == {
"x.com.samsung.da.modelNum": "SIBLING_MODEL",
"x.com.samsung.da.serialNum": "SIBLING-SERIAL",
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert extra[f"/{_UUID}/mode/vs/0"] == {"m": "cool"}
def test_enumerate_prefixed_flat_fallback_is_a_no_op_with_no_master_hrefs_to_clone():
"""A degenerate `resources` (nothing at all, not even /subdevices/vs/0 --
can't happen via the subdeviceIdList path but shared by _probe_prefixed
with Pattern C, whose ids come from /oic/res instead) must not crash the
fallback -- there's nothing to clone, so nothing materializes."""
subdevices, extra = enumerate_subdevices(
_FakeSession({}),
{},
oic_res_links=[{"href": f"/{_UUID}/multidevice/vs/0"}],
)
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_probe_log_only_reports_seed_and_information():
"""Only /<uuid>/device/0 (the seed) and /<uuid>/information/vs/0 (the one
bounded exception -- so the sibling gets its own model/serial rather
than the master's cloned ones) are ever probed over the network now --
no more per-href probing loop to log."""
resources = {
"/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]},
"/mode/vs/0": {"m": "cool"},
}
probes: dict[str, bool] = {}
enumerate_subdevices(
_FakeSession({}), resources, oic_res_links=[], probe_log=probes.__setitem__
)
assert probes[f"/{_UUID}/device/0"] is False
assert probes[f"/{_UUID}/information/vs/0"] is False
allowed = {f"/{_UUID}/device/0", f"/{_UUID}/information/vs/0"}
assert not any(href.startswith(f"/{_UUID}/") and href not in allowed for href in probes)
def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uuid():
"""Two prefixed candidates in the same subdeviceIdList, one whose
Collection endpoint works and one that needs the flat fallback -- each
@@ -379,9 +407,7 @@ def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uu
_DEVCOL_REP,
{"href": "/mode/vs/0", "rep": {"m": "a-collection"}},
],
# uuid_b's Collection deliberately absent -> falls back to the flat
# per-href probe.
(uuid_b, "mode", "vs", "0"): {"m": "b-flat"},
# uuid_b's Collection deliberately absent -> falls back to cloning.
}
)
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
@@ -391,10 +417,11 @@ def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uu
assert by_key[uuid_a].seed_path == (uuid_a, "device", "0")
assert by_key[uuid_a].flat_hrefs == ()
assert by_key[uuid_b].seed_path == ()
assert by_key[uuid_b].flat_hrefs == ("/mode/vs/0",)
assert by_key[uuid_b].flat_hrefs == ("/mode/vs/0", "/subdevices/vs/0")
assert extra == {
f"/{uuid_a}/mode/vs/0": {"m": "a-collection"},
f"/{uuid_b}/mode/vs/0": {"m": "b-flat"},
f"/{uuid_b}/mode/vs/0": {"m": "cool"},
f"/{uuid_b}/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [uuid_a, uuid_b]},
}