diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 57078d7..1feae3f 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -399,6 +399,24 @@ 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 diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index d81a8e3..f095f4c 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -128,6 +128,23 @@ _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] = { @@ -363,7 +380,15 @@ 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).""" + 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. + """ key = (href, code) if key in self._warned_unmapped: return @@ -371,7 +396,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, + self.entity_id or self.unique_id, code, href, ) @@ -472,7 +497,11 @@ 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: + if ( + device is not None + and device not in _DEVICE_TO_HVAC + and device not in _NON_HVAC_OPTION_CODES + ): self._warn_unmapped(MODE_HREF, device) return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO) @@ -480,7 +509,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: + if m == _AI_COMFORT_MODE or m in _NON_HVAC_OPTION_CODES: continue mapped = _DEVICE_TO_HVAC.get(m) if mapped is None: diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 917f7cd..d1fbce0 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -259,6 +259,7 @@ _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", diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 5a84e16..e76e193 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -33,8 +33,11 @@ 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, diff --git a/custom_components/localthings/registry/by_type/dehumidifier.py b/custom_components/localthings/registry/by_type/dehumidifier.py index 373fbdc..ee3b041 100644 --- a/custom_components/localthings/registry/by_type/dehumidifier.py +++ b/custom_components/localthings/registry/by_type/dehumidifier.py @@ -2,10 +2,13 @@ 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. +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. """ -from ..capabilities import airconditioner, common, dehumidifier, ignored +from ..capabilities import air_purifier, airconditioner, common, dehumidifier, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -17,9 +20,11 @@ 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, ] ), diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 86228b7..8e4f684 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -28,6 +28,7 @@ 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, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 3da18bd..d0de28d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -909,6 +909,80 @@ 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", @@ -927,6 +1001,51 @@ 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. diff --git a/custom_components/localthings/registry/capabilities/dehumidifier.py b/custom_components/localthings/registry/capabilities/dehumidifier.py index 27c0e5b..944d62b 100644 --- a/custom_components/localthings/registry/capabilities/dehumidifier.py +++ b/custom_components/localthings/registry/capabilities/dehumidifier.py @@ -9,7 +9,7 @@ entities rather than folded into one card. """ from ..capability import Capability -from ..entities import NumberDesc, SelectDesc, SensorDesc +from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc from .common import int_or_none @@ -77,6 +77,60 @@ 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 diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index f993f63..dff15b0 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -48,6 +48,18 @@ 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( diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index d903b6c..592ad79 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -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 +from .laundry import cycle_select, drum_clean_cycles_remaining, drum_clean_last_cleaned def _wrinkle_write(p, rep, href=None): @@ -41,14 +41,25 @@ 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 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 +# 2026-05-29). 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). 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. +# #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. DRYER_COURSE = Capability( href="/course/vs/0", entities=( @@ -57,6 +68,22 @@ 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, + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 512f0fe..42a827d 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -163,6 +163,34 @@ 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 # --------------------------------------------------------------------------- diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 802e371..512d4b8 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -20,6 +20,7 @@ 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 @@ -234,6 +235,47 @@ 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_ is the wash/dry-cycle interval +# between recommended cleans; WashingTimes_ 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 diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index ef41fe4..2a78d4b 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -15,8 +15,6 @@ 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 ( @@ -24,6 +22,8 @@ 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,36 +149,10 @@ WASHER_SETTINGS = Capability( # --------------------------------------------------------------------------- -# Drum Clean+ maintenance tracking, from the same options[] array as the -# selected course. DrumCleanProposal_ is the wash-cycle interval between -# recommended cleans; WashingTimes_ 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 +# 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. # Detergent/softener auto-dispense dosing, from the same options[] array @@ -340,16 +314,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", diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 732049b..f2094c1 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -278,9 +278,9 @@ "name": "Cyklus", "state": { "80": "Jemné", - "83": "Express 60", + "83": "Normální", "84": "Intenzivní", - "86": "Normální", + "86": "Express 60", "90": "Samočištění", "0e": "AI mytí", "07": "Předoplach", @@ -325,7 +325,10 @@ "1e": "Outdoor", "1f": "Smíšená náplň", "2b": "Sušení bubnu", - "4c": "Osvěžení vzduchem" + "4c": "Osvěžení vzduchem", + "51": "Eko bavlna", + "53": "AI sušení+", + "4e": "Automatické sušení" } }, "favorite_capacity": { @@ -621,6 +624,28 @@ }, "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": { @@ -969,6 +994,15 @@ }, "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": { @@ -1136,6 +1170,15 @@ }, "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": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 9a9ba06..44ec3e7 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -278,9 +278,9 @@ "name": "Cycle", "state": { "80": "Delicate", - "83": "Express 60", + "83": "Normal", "84": "Heavy", - "86": "Normal", + "86": "Express 60", "90": "Self clean", "0e": "AI Wash", "07": "Pre blast", @@ -325,7 +325,10 @@ "1e": "Outdoor", "1f": "Mixed load", "2b": "Self Tub Dry", - "4c": "Air Refresh" + "4c": "Air Refresh", + "51": "Eco Cotton", + "53": "AI Dry+", + "4e": "Self Dry" } }, "favorite_capacity": { @@ -621,6 +624,28 @@ }, "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": { @@ -969,6 +994,15 @@ }, "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": { @@ -1136,6 +1170,15 @@ }, "away_mode": { "name": "Away mode" + }, + "watertank_light": { + "name": "Water tank light" + }, + "uv_led": { + "name": "UV LED" + }, + "ventilation_alarm": { + "name": "Ventilation alarm" } }, "time": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 55f2f99..71b9e3a 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -392,9 +392,9 @@ "name": "Ciclo", "state": { "80": "Delicado", - "83": "Express 60", + "83": "Normal", "84": "Intenso", - "86": "Normal", + "86": "Express 60", "90": "Autolimpieza", "0e": "Lavado IA", "07": "Prelavado intenso", @@ -439,7 +439,10 @@ "1e": "Exterior", "1f": "Carga mixta", "2b": "Secado de tambor", - "4c": "Renovación de aire" + "4c": "Renovación de aire", + "51": "Eco algodón", + "53": "Secado IA+", + "4e": "Autosecado" } }, "favorite_capacity": { @@ -735,6 +738,28 @@ }, "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": { @@ -1083,6 +1108,15 @@ }, "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": { @@ -1250,6 +1284,15 @@ }, "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": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 5d7f61c..44383cf 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -278,9 +278,9 @@ "name": "Ciclo", "state": { "80": "Delicato", - "83": "Express 60", + "83": "Normale", "84": "Intenso", - "86": "Normale", + "86": "Express 60", "90": "Autopulizia", "0e": "AI Wash", "07": "Prelavaggio intenso", @@ -325,7 +325,10 @@ "1e": "Capi outdoor", "1f": "Carico misto", "2b": "Asciugatura cestello", - "4c": "Rinfresca" + "4c": "Rinfresca", + "51": "Eco cotone", + "53": "Asciugatura AI+", + "4e": "Autoasciugatura" } }, "favorite_capacity": { @@ -621,6 +624,28 @@ }, "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": { @@ -969,6 +994,15 @@ }, "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": { @@ -1136,6 +1170,15 @@ }, "away_mode": { "name": "Modalità assenza" + }, + "watertank_light": { + "name": "Luce del serbatoio dell'acqua" + }, + "uv_led": { + "name": "LED UV" + }, + "ventilation_alarm": { + "name": "Allarme ventilazione" } }, "time": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index d65dcf5..c8ea62f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -278,9 +278,9 @@ "name": "Programma", "state": { "80": "Delicaat", - "83": "Express 60", + "83": "Normaal", "84": "Intensief", - "86": "Normaal", + "86": "Express 60", "90": "Zelfreiniging", "0e": "AI Wash", "07": "Voorspoelen", @@ -325,7 +325,10 @@ "1e": "Outdoor", "1f": "Gemengde was", "2b": "Zelf trommel drogen", - "4c": "Opfrissen" + "4c": "Opfrissen", + "51": "Eco katoen", + "53": "AI drogen+", + "4e": "Zelf drogen" } }, "favorite_capacity": { @@ -621,6 +624,28 @@ }, "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": { @@ -969,6 +994,15 @@ }, "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": { @@ -1136,6 +1170,15 @@ }, "away_mode": { "name": "Afwezigheidsmodus" + }, + "watertank_light": { + "name": "Waterreservoirlicht" + }, + "uv_led": { + "name": "Uv-led" + }, + "ventilation_alarm": { + "name": "Ventilatiealarm" } }, "time": { diff --git a/tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json b/tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json new file mode 100644 index 0000000..dad24a2 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json @@ -0,0 +1,641 @@ +{ + "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" + } + } + ] +} diff --git a/tests/fixtures/dehumidifier_tp1x_dhm01001_device.json b/tests/fixtures/dehumidifier_tp1x_dhm01001_device.json new file mode 100644 index 0000000..4bcdda5 --- /dev/null +++ b/tests/fixtures/dehumidifier_tp1x_dhm01001_device.json @@ -0,0 +1,426 @@ +{ + "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" + } + } + ] +} diff --git a/tests/fixtures/dryer_tp1_21_drum_clean_device.json b/tests/fixtures/dryer_tp1_21_drum_clean_device.json new file mode 100644 index 0000000..a839211 --- /dev/null +++ b/tests/fixtures/dryer_tp1_21_drum_clean_device.json @@ -0,0 +1,438 @@ +{ + "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" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index c00c271..30ee055 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -2,6 +2,9 @@ "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", @@ -29,6 +32,7 @@ "selfcheck_result", "selfcheck_status", "super_fine_dust", - "tropical_night_mode" + "tropical_night_mode", + "uv_led" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json b/tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json new file mode 100644 index 0000000..a61e80e --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json @@ -0,0 +1,33 @@ +{ + "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" + ] +} diff --git a/tests/fixtures/golden/dehumidifier_tp1x_dhm01001.json b/tests/fixtures/golden/dehumidifier_tp1x_dhm01001.json new file mode 100644 index 0000000..fc13d8f --- /dev/null +++ b/tests/fixtures/golden/dehumidifier_tp1x_dhm01001.json @@ -0,0 +1,27 @@ +{ + "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" + ] +} diff --git a/tests/fixtures/golden/dryer_tp1_21_drum_clean.json b/tests/fixtures/golden/dryer_tp1_21_drum_clean.json new file mode 100644 index 0000000..b10ac51 --- /dev/null +++ b/tests/fixtures/golden/dryer_tp1_21_drum_clean.json @@ -0,0 +1,28 @@ +{ + "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" + ] +} diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_definite.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_definite.json new file mode 100644 index 0000000..84b6148 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_definite.json @@ -0,0 +1,30 @@ +{ + "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" + ] +} diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index da6f20c..4fc1806 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -10,6 +10,7 @@ "detergent_low", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "dry_level", "energy_kwh", "finish_time", diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json index e100a59..b752199 100644 --- a/tests/fixtures/golden/washer_flexwash.json +++ b/tests/fixtures/golden/washer_flexwash.json @@ -9,6 +9,7 @@ "detergent_low", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "finish_time", "job_beginning_status", diff --git a/tests/fixtures/golden/washer_wa55a7700av.json b/tests/fixtures/golden/washer_wa55a7700av.json index 930f097..069b0a1 100644 --- a/tests/fixtures/golden/washer_wa55a7700av.json +++ b/tests/fixtures/golden/washer_wa55a7700av.json @@ -11,6 +11,7 @@ "detergent_water_hardness", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "finish_time", "firmware_update", @@ -27,4 +28,4 @@ "spin_speed", "wash_temperature" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/golden/washer_wa8000t.json b/tests/fixtures/golden/washer_wa8000t.json index b0e0ef6..7cfedb5 100644 --- a/tests/fixtures/golden/washer_wa8000t.json +++ b/tests/fixtures/golden/washer_wa8000t.json @@ -9,6 +9,7 @@ "detergent_low", "diagnosis_status", "drum_clean_cycles_remaining", + "drum_clean_last_cleaned", "energy_kwh", "finish_time", "firmware_update", diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_definite_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_definite_device.json new file mode 100644 index 0000000..54c2cd3 --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_definite_device.json @@ -0,0 +1,467 @@ +{ + "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 + } + } + } + ] +} diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index e1e5bc7..99e1c27 100644 --- a/tests/test_airconditioner_cac.py +++ b/tests/test_airconditioner_cac.py @@ -6,12 +6,18 @@ 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 -- 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. +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. """ from custom_components.localthings.registry.adapter import flatten @@ -22,7 +28,6 @@ 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", @@ -30,7 +35,6 @@ _STILL_UNBOUND = frozenset( "/settings/sound/output/vs/0", "/settings/sound/volume/vs/0", "/smartsensingcooling/vs/0", - "/uvled/vs/0", } ) diff --git a/tests/test_airconditioner_tp1x_fac_time_23k.py b/tests/test_airconditioner_tp1x_fac_time_23k.py new file mode 100644 index 0000000..2b677fb --- /dev/null +++ b/tests/test_airconditioner_tp1x_fac_time_23k.py @@ -0,0 +1,83 @@ +"""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) diff --git a/tests/test_climate_homecare_wizard.py b/tests/test_climate_homecare_wizard.py new file mode 100644 index 0000000..cd4e58d --- /dev/null +++ b/tests/test_climate_homecare_wizard.py @@ -0,0 +1,80 @@ +"""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 diff --git a/tests/test_dehumidifier_tp1x_dhm01001_capabilities.py b/tests/test_dehumidifier_tp1x_dhm01001_capabilities.py new file mode 100644 index 0000000..6a0e74a --- /dev/null +++ b/tests/test_dehumidifier_tp1x_dhm01001_capabilities.py @@ -0,0 +1,94 @@ +"""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" diff --git a/tests/test_dryer_drum_clean.py b/tests/test_dryer_drum_clean.py new file mode 100644 index 0000000..fedb8d4 --- /dev/null +++ b/tests/test_dryer_drum_clean.py @@ -0,0 +1,87 @@ +"""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 diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 3d755ae..6ad4e4b 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -329,6 +329,56 @@ 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/ diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index b88f5a0..dafc1fb 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -72,7 +72,12 @@ 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'.""" + _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.""" from tests.conftest import _load_device resources = _load_device("washer_wa8000t") @@ -270,7 +275,10 @@ 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.""" + consumer-prefix map entirely -- issue #19. + + Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test + above for why.""" from tests.conftest import _load_device resources = _load_device("washer_flexwash") @@ -285,7 +293,10 @@ 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.""" + /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.""" from tests.conftest import _load_device resources = _load_device("washer_dryer_combo") @@ -622,7 +633,10 @@ 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.""" + unbound hrefs. + + Also gains drum_clean_last_cleaned (issue #258) -- see the wa8000t test + above for why.""" from tests.conftest import _load_device resources = _load_device("washer_wa55a7700av") @@ -1226,6 +1240,79 @@ 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