diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 5861bd6..d7a2477 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -6,6 +6,7 @@ description: >- raises the "incomplete capability coverage" repair, a diagnostics JSON needs triaging, or you're mapping OCF resources to HA entities. Covers reading dumps, OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy, + preferring dynamic (device-reported) select options over hardcoded lists, ensuring every href is bound or ignored, and locking it in with a fixture + golden + test. --- @@ -103,7 +104,37 @@ sub-polled between summary polls. Pick descriptor types from `entities.py` as a gap for a human, or ignore it with a documented reason — never invent an entity on a hunch (`ignored.py`'s rule). -## 5. Names and enum labels live in translations, never in Python +## 5. Select options: read them from the device, don't hardcode + +A `SelectDesc`'s `options` should come from the device's own advertised list +whenever the resource carries one, not from a Python tuple typed in from a +single dump. Two dynamic forms already exist in the repo and should be +reached for first: +- `options_field='x.com.samsung.da.supportedModes'` (or whatever the + resource's own supported-values field is called) — reads the live rep on + the capability's own href. See `laundry.py`'s `buzzer_sound`/ + `finish_sound` (`options_field='supportedBuzzerSound'`/ + `'supportedFinishSound'`). +- `options=` — for option lists that live on a **different** + resource than the select's own href (e.g. a course table keyed off a + sibling href). See `laundry.cycle_select`'s `options=cycle_options`. + +A static `options=(...)` tuple is a coverage gap waiting to happen: the next +dump from a different board generation will report modes/values the tuple +doesn't have, and both the HA options list *and* `write_fn`'s validation (if +it checks the same tuple) will silently reject values the device itself +advertises as supported. That's exactly what happened with `oven._OVEN_MODES` +in issue #138 — a hardcoded list rejected `AirFryer`/`Dehydrate`/ +`SelfClean`/etc. even though the device's own `supportedModes` field listed +them. Reach for a static tuple only when the dump genuinely has no +supported-values field to read (e.g. the NV7000BS-class oven dump +`_OVEN_MODES` was inferred before any live oven dump existed — see that +module's docstring), and treat it as an interim best-guess rather than a +permanent design choice: migrate it to `options_field`/a callable the moment +a dump with a real supported-values list surfaces, instead of just adding +the new values to the static tuple. + +## 6. Names and enum labels live in translations, never in Python Descriptors have **no `name` field**. Every entity is named from the shipped catalog, keyed by `translation_key` — which defaults to the descriptor's own @@ -142,7 +173,7 @@ 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`. -## 6. Coverage discipline: bound or ignored +## 7. Coverage discipline: bound or ignored Every href in the dump must resolve, or the repair fires. If a resource isn't worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`) @@ -156,7 +187,7 @@ friendlier href**. ignored because washers bind it. When only one family should ignore an href that another binds, scope the ignore to that family's registry. -## 7. Reuse before writing new code +## 8. Reuse before writing new code Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py` (shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course @@ -165,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share `laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a shared module rather than copying. -## 8. Lock it in +## 9. Lock it in 1. Add a **scrubbed** fixture `tests/fixtures/_device.json` (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, diff --git a/README.md b/README.md index 3ac35d0..f98a9e3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Washer | `by_type/washer.py` | | Water purifier | `by_type/water_purifier.py` | | Vacuum clean/auto-empty station | `by_type/vacuum_station.py` | +| Air dresser | `by_type/air_dresser.py` | Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with. diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 5ba2137..c283cce 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -51,6 +51,7 @@ from .registry.capabilities.airconditioner import ( HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, HREF_AIRFLOW as AIRFLOW_HREF, + is_legacy_board, ) from .registry.capabilities.common import normalize_temp_unit @@ -142,6 +143,22 @@ def _oscillation_swing(rep: dict) -> str | None: return 'horizontal' return 'off' +def _wind_strength_label(code, rep: dict) -> str: + """Human label for a /wind/strength/vs/0 code from the device's own + modesName array (parallel-indexed with supportedModes), lowercased for + HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue + #155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the + "0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving + "Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors + preset_mode's dynamic code->str resolution. Falls back to the raw code + lowercased when modesName is absent or misaligned.""" + supported = rep.get('x.com.samsung.da.supportedModes') or [] + names = rep.get('x.com.samsung.da.modesName') or [] + if code in supported and len(names) == len(supported): + return str(names[supported.index(code)]).lower() + return str(code).lower() + + # Preset (convenient mode): resolved dynamically from the device's own # /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' # code maps to HA's PRESET_NONE ("no preset active"); every other code is @@ -247,8 +264,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _legacy_airflow(self) -> dict: """The /airflow/vs/0 rep, but only when it is the fan/swing channel to - use -- i.e. this board has no /wind/strength/vs/0.""" - if self.coordinator.resource(WIND_STRENGTH_HREF): + use -- i.e. this board has no /wind/strength/vs/0. + + Delegates the board-generation test to is_legacy_board (the same + test capabilities/airconditioner.py's token entities are gated on) + instead of re-implementing it. Uses last_resources rather than a + two-key presence dict built from coordinator.resource()'s truthiness + -- resource() collapses "href absent" and "href present with an + empty {} rep" to the same falsy value, while is_legacy_board (and + discover()'s own binding) test key membership, not truthiness. A + presence dict built from truthiness alone would disagree with the + token entities on a board reporting a genuinely empty /airflow/vs/0, + silently reintroducing the drift this delegation exists to prevent. + """ + if not is_legacy_board(self.coordinator.last_resources): return {} return self.coordinator.resource(AIRFLOW_HREF) or {} @@ -414,14 +443,24 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): airflow = self._legacy_airflow() if airflow: return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel'))) - return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + rep = self._rep(WIND_STRENGTH_HREF) + code = _first(rep.get(_MODES_FIELD)) + if code is None: + return None + return _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep) @property def fan_modes(self) -> list[str]: if self._legacy_airflow(): # This resource carries no supportedModes, so the full scale is offered. return list(_DEVICE_TO_FAN.values()) - return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + rep = self._rep(WIND_STRENGTH_HREF) + modes = [] + for code in self._supported(WIND_STRENGTH_HREF): + mode = _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep) + if mode not in modes: + modes.append(mode) + return modes def _swing_via_direction(self) -> bool: """True when WIND_DIRECTION_HREF is the swing channel to use -- @@ -523,7 +562,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self.coordinator.async_send_command( self._bound, ('fan_legacy', level)) return - await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) + supported = self._supported(WIND_STRENGTH_HREF) + device = _FAN_TO_DEVICE.get(fan_mode) + # A static hit is only trustworthy if this unit's own supportedModes + # actually includes that code -- a board can use non-standard codes + # (issue #155's "31"-"35") while still spelling a standard label + # ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would + # return a plausible-looking code ('1'/'3') the device never + # advertised at all. Fall through to the live scan whenever the + # static guess isn't actually one of this unit's own codes. + if device is None or (supported and device not in supported): + rep = self._rep(WIND_STRENGTH_HREF) + for code in supported: + if _wind_strength_label(code, rep) == fan_mode: + device = code + break + if device is not None: + await self.coordinator.async_send_command(self._bound, ('fan', device)) async def async_set_swing_mode(self, swing_mode: str) -> None: if self._legacy_airflow(): diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index d7591f1..65c728a 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -366,14 +366,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if reg is None: reg = for_device_by_resources(resources) unbound: list[str] = [] + hot, warm = set(), set() + + def _tier_log(href: str, tier: str) -> None: + if tier == 'hot': + hot.add(href) + elif tier == 'warm': + warm.add(href) + if reg is not None: self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui) bound = discover(resources, reg.capabilities, reg.pattern_capabilities, - log=unbound.append) + log=unbound.append, tier_log=_tier_log) self.device_type_name = reg.name else: self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui) - bound = discover(resources, CAPABILITIES, log=unbound.append) + bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log) self.device_type_name = None self.bound = bound self._unbound_hrefs = unbound @@ -398,13 +406,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self._update_coverage_gap_issue(reg is None, unbound, name) - hot, warm = set(), set() - for be in bound: - tier = be.capability.poll_tier - if tier == 'hot': - hot.add(be.href) - elif tier == 'warm': - warm.add(be.href) self._hot_hrefs = sorted(hot) self._warm_hrefs = sorted(warm) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9c9d623..68f18fa 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -1,15 +1,18 @@ """Fan platform for Samsung range hoods and air purifiers. -Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry +Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry below since each needs different HA fan semantics: the range hood's fan speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/ Medium/High (issue #56) are both an ordered set of numeric levels (SET_SPEED) -- the latter confirmed monotonic in capabilities/ air_purifier.py's module docstring, with no named-mode list to preserve -since this board never self-reports one. The newer TP1X air-purifier -family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named -behaviors with no linear order (PRESET_MODE), reported directly by that -board's own supportedModes.""" +since this board never self-reports one. The TP1X air-purifier family's +modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21 +family's /wind/strength/vs/0 modes (issue #151) are both named behaviors +with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles +both hrefs, the only difference being whether the label comes straight +from supportedModes or from a parallel modesName array (see +_label_for_code).""" from __future__ import annotations @@ -29,6 +32,7 @@ from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included from .registry.capabilities.air_purifier import HREF_AIRFLOW from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF +from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF from .registry.entities import FanDesc _LOGGER = logging.getLogger(__name__) @@ -42,6 +46,7 @@ _OFF_SPEED_CODE = '0' _MODES_FIELD = 'x.com.samsung.da.modes' _SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes' +_MODES_NAME_FIELD = 'x.com.samsung.da.modesName' async def async_setup_entry( @@ -54,7 +59,7 @@ async def async_setup_entry( for bound in coordinator.bound: if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)): continue - if bound.href == AIR_PURIFIER_FAN_HREF: + if bound.href in (AIR_PURIFIER_FAN_HREF, AIR_PURIFIER_WIND_STRENGTH_HREF): entities.append(LocalThingsAirPurifierFan(coordinator, bound)) elif bound.href == HREF_AIRFLOW: entities.append(LocalThingsAirflowFan(coordinator, bound)) @@ -243,10 +248,29 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): return str(power).lower() == 'on' return bool(self._rep(POWER_HREF).get('value')) + def _label_for_code(self, code) -> str: + """Lowercased HA preset label for a device mode code. + + The TP1X_DA-AC-AIR board (issue #130) reports its named modes + directly as supportedModes ('Smart'/'Max'/...), so the code IS the + label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric + wind-strength codes ('87'/'89'/...) with a separate modesName array + (parallel-indexed with supportedModes) giving the actual names -- + same shape as climate.py's _wind_strength_label, and coincidentally + the same word set (Smart/Max/WindFree/Sleep), so both board + generations land on identical HA preset values without needing + their own translation catalog entry.""" + rep = self._mode_rep() + supported = list(rep.get(_SUPPORTED_MODES_FIELD, ())) + names = rep.get(_MODES_NAME_FIELD) + if names and code in supported and len(names) == len(supported): + return str(names[supported.index(code)]).lower() + return str(code).lower() + @property def preset_modes(self) -> list[str]: return [ - str(code).lower() + self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()) ] @@ -254,7 +278,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): def preset_mode(self) -> str | None: modes = self._mode_rep().get(_MODES_FIELD) code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes - return str(code).lower() if code is not None else None + return self._label_for_code(code) if code is not None else None async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, @@ -269,10 +293,10 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: # Reverse-resolve against the unit's own supportedModes -- the - # write needs the raw device code (e.g. 'WindFree'), not the - # lowercased HA value. + # write needs the raw device code (e.g. 'WindFree', or '90' on the + # modesName-labelled board), not the lowercased HA value. for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()): - if str(code).lower() == preset_mode: + if self._label_for_code(code) == preset_mode: await self.coordinator.async_send_command(self._bound, ('mode', code)) return _LOGGER.warning( diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 1a9b9aa..c38e89a 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,9 +3,9 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - induction_cooktop, microwave, oven, range as _range, range_hood, - refrigerator, vacuum_station, washer, water_purifier, + air_dresser, air_purifier, airconditioner, cooktop, dehumidifier, + dishwasher, dryer, induction_cooktop, microwave, oven, range as _range, + range_hood, refrigerator, vacuum_station, washer, water_purifier, ) __all__ = [ @@ -15,6 +15,7 @@ __all__ = [ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { + 'air_dresser': air_dresser.REGISTRY, 'air_purifier': air_purifier.REGISTRY, 'airpurifier': air_purifier.REGISTRY, 'airconditioner': airconditioner.REGISTRY, @@ -200,6 +201,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # here) -- no WAC-specific resources needed. if key is None and '_WAC_' in (model_num or ''): key = 'airconditioner' + # Wind-Free 2-in-1 systems (floor-standing + wall-mounted indoor units + # sharing one outdoor unit and one local IP, e.g. TP2X_FAC_BORA_21K, + # issues #150/#153) report no oneUiVersion and carry the '_FAC_' token + # instead of '_RAC_'/'_PRAC_'. Same TP1X/TP2X-class resource surface as + # the room-AC models above. + if key is None and '_FAC_' in (model_num or ''): + key = 'airconditioner' # ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/ # #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at # all -- the board family is spelled 'ARA-WW-' instead. Same resource @@ -222,6 +230,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi key = 'airconditioner' if key is None and '_TVTL_' in (model_num or ''): key = 'air_purifier' + # BESPOKE Cube Air (e.g. A-VTWW-TP2-21-COMMON, issue #151) reports no + # oneUiVersion and carries the hyphenated '-VTWW-' board-family token + # (distinct from the underscore-delimited '_TVTL_' ARTIK051 family + # above). Its fan lives on /wind/strength/vs/0 rather than /mode/vs/0 -- + # see capabilities/air_purifier.py's WIND_STRENGTH_FAN. + if key is None and '-VTWW-' in (model_num or '').upper(): + key = 'air_purifier' model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' @@ -270,6 +285,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # station state) shares no hrefs with anything else already modeled. if key is None and '-VSKR-' in (model_num or '').upper(): key = 'vacuum_station' + # AirDresser (e.g. DA_DF_A51_20_COMMON, issue #162) -- reports no + # oneUiVersion and carries the '_DF_' (Dresser Function) board-family + # token. Every resource it exposes (course select, wrinkle-prevent + # setting, diagnosis) is already handled by the shared laundry + # machinery; it needs its own device type only because none of the + # washer/dryer/dishwasher consumer-model prefixes below match it. + if key is None and '_DF_' in (model_num or ''): + key = 'air_dresser' # Consumer-model prefix from `description` (washer/dryer/dishwasher) -- # last, since it's the fuzziest match (a bare 2-letter prefix) and would # otherwise shadow the more specific board-family tokens above. diff --git a/custom_components/localthings/registry/by_type/air_dresser.py b/custom_components/localthings/registry/by_type/air_dresser.py new file mode 100644 index 0000000..db3d526 --- /dev/null +++ b/custom_components/localthings/registry/by_type/air_dresser.py @@ -0,0 +1,24 @@ +"""AirDresser device registry (issue #162). + +Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning +status, operational state, and diagnosis. air_dresser.py holds the two +pieces specific to this device type: a minimal wrinkle-prevent-only +/washer/vs/0 capability, and the course select's own translation key. +""" +from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='air_dresser', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + air_dresser.AIR_DRESSER_SETTINGS, + air_dresser.AIR_DRESSER_COURSE, + air_dresser.AIR_DRESSER_SANITIZE, + laundry.JOB_BEGINNING_STATUS, + operational.OPERATIONAL_STATE, + dishwasher.DIAGNOSIS, + ]), +) diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 2648aec..cf87fba 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -1,6 +1,6 @@ """Air-purifier device registry. -Spans two board generations sharing this one registry (see +Spans three board generations sharing this one registry (see capabilities/air_purifier.py's module docstring for the per-href match_fn discriminators that keep them from colliding): @@ -12,6 +12,10 @@ match_fn discriminators that keep them from colliding): reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on the shared DA-AC- board family. +- A-VTWW-TP2-21-COMMON-class (issue #151). Reports no oneUiVersion and no + existing modelNum token; falls back to unknown until routed here. Its fan + is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on + /mode/vs/0 -- see that capability's comment. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write contract). @@ -33,6 +37,7 @@ REGISTRY = DeviceRegistry( air_purifier.AIRFLOW_VS_FALLBACK, air_purifier.MODE, air_purifier.FAN, + air_purifier.WIND_STRENGTH_FAN, air_purifier.DISPLAY, air_purifier.HEPA_FILTER, air_purifier.PANEL_STATUS, diff --git a/custom_components/localthings/registry/capabilities/air_dresser.py b/custom_components/localthings/registry/capabilities/air_dresser.py new file mode 100644 index 0000000..3fcfb11 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/air_dresser.py @@ -0,0 +1,80 @@ +"""Capabilities specific to the AirDresser family (Samsung DA_DF-class, +issues #162/#157). + +This board family reports no oneUiVersion and no /information/vs/0 token +any existing family routes on, so it gets its own device type -- but most +of the resources it exposes are already handled by the shared laundry +machinery: + + /washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a + dedicated capability rather than reusing + dryer.DRYER_SETTINGS wholesale: this board never + populates dryLevel/dryTime/dryerType at all, and + those are permanently-empty-not-just-absent + dryer-only fields on an AirDresser, not merely unset + ones, so binding them here would ship three sensors + that can never read anything on this device type) + /course/vs/0 -> AIR_DRESSER_COURSE (cycle select), table id from + /st/airdressercourse/vs/0 (issue #157's + DA_DF_TP2_20_COMMON reports "Table_00"; issue #162's + DA_DF_A51_20_COMMON has no table resource at all and + falls back to the name-only 'cycle' key, same as an + unrecognized table on washer/dryer). Options come + from laundry.cycle_options -- #157's board populates + /wm/editcourse/vs/0's editCourseList directly; #162's + has no editcourse resource at all and falls through + to cycle_options' supportedOptions decode instead. + Course names aren't identified yet for either table + (no code->name mapping was reported), so they render + as their raw codes until named in translations, same + as dryer.py's unidentified codes. + /diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS + /airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only; + #162's board doesn't report this resource at all) +""" +from ..capability import Capability +from ..entities import SwitchDesc +from .laundry import cycle_select + + +def _wrinkle_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} + + +def _sanitize_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p} + + +AIR_DRESSER_SETTINGS = Capability( + href='/washer/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent', + icon='mdi:iron', + value_fn=lambda v: v == 'On', + write_fn=_wrinkle_write), + ), +) + +AIR_DRESSER_COURSE = Capability( + href='/course/vs/0', + entities=( + cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew', + table_href='/st/airdressercourse/vs/0'), + ), +) + +AIR_DRESSER_SANITIZE = Capability( + href='/airdresseroption/sanitize/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize', + icon='mdi:weather-sunny', + value_fn=lambda v: v == 'On', + write_fn=_sanitize_write), + ), +) diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index f9ba661..aa80c94 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -74,6 +74,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option # are mutually exclusive via this presence check rather than colliding. HREF_MODE = '/mode/vs/0' HREF_AIRFLOW = '/airflow/0' +HREF_WIND_STRENGTH = '/wind/strength/vs/0' def _has_top_level_modes(rep, resources): @@ -136,14 +137,22 @@ DEVICE_ACTIVE = Capability( ), ) +def _power_write(power_href, value): + """Shared 'power' payload handling for this family's three FanDesc write + functions -- targets whichever power href fan.py's _power_payload picked + (the board may only report /power/0); a hardcoded vendor href here would + silently no-op on such a board even though the entity's own is_on + already falls back to reading it correctly.""" + if power_href == '/power/0': + return ['power', '0'], {'value': bool(value)} + return (['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if value else 'Off'}) + + def _airflow_fan_write(payload, rep, href=None): kind, value, *args = payload if kind == 'power': - power_href = args[0] if args else '/power/vs/0' - if power_href == '/power/0': - return ['power', '0'], {'value': bool(value)} - return (['power', 'vs', '0'], - {'x.com.samsung.da.power': 'On' if value else 'Off'}) + return _power_write(args[0] if args else '/power/vs/0', value) if kind == 'speed': return ['airflow', '0'], {'speed': int(value)} return None @@ -233,15 +242,7 @@ MODE = Capability( def _fan_write(payload, rep, href=None): kind, value, *args = payload if kind == 'power': - # Targets whichever power href fan.py's _power_payload picked (the - # board may only report /power/0) -- a hardcoded vendor href here - # would silently no-op on such a board even though the entity's - # own is_on already falls back to reading it correctly. - power_href = args[0] if args else '/power/vs/0' - if power_href == '/power/0': - return ['power', '0'], {'value': bool(value)} - return (['power', 'vs', '0'], - {'x.com.samsung.da.power': 'On' if value else 'Off'}) + return _power_write(args[0] if args else '/power/vs/0', value) if kind == 'mode': return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]} return None @@ -272,6 +273,38 @@ FAN = Capability( ), ) + +def _wind_strength_fan_write(payload, rep, href=None): + kind, value, *args = payload + if kind == 'power': + return _power_write(args[0] if args else '/power/vs/0', value) + if kind == 'mode': + return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value} + return None + + +# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but +# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of +# self-describing supportedModes -- x.com.samsung.da.modesName gives the +# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's +# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded +# per-model map. modes here is a bare string ('87'), not a single-element +# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is. +# +# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry +# and also uses a FanDesc; BoundEntity's unique_id is built from key alone +# (entity.py's _key), not href, so two same-key FanDescs in one registry +# would collide if a board ever bound both (see AIRFLOW_GENERIC's own +# comment on this exact hazard -- missed here in the initial cut). +WIND_STRENGTH_FAN = Capability( + href=HREF_WIND_STRENGTH, + poll_tier='warm', + entities=( + FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan', + field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write), + ), +) + # --------------------------------------------------------------------------- # TP1X_DA-AC-AIR-class additions (issue #130). This board reports several # resources the older ARTIK051_TVTL family never did. @@ -425,4 +458,8 @@ COVERAGE = [ # functionState both 'false'). Same "needs a multi-field schedule # editor" treatment as fridge.py's /defrost/reservation/vs/0. Capability(href='/dnd/autosleep/vs/0'), + # Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's + # convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN + # above instead. + Capability(href='/mode/convenient/vs/0'), ] diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 07ff46e..d2cafa3 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -264,7 +264,7 @@ def _option_token(rep, prefix): return None -def _is_legacy_board(resources): +def is_legacy_board(resources): """True for the board generation whose airflow lives in /airflow/vs/0. Newer families carry several of the same option tokens (Volume, Sleep, @@ -275,13 +275,12 @@ def _is_legacy_board(resources): /airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities below and the climate entity can never disagree about the generation. """ - return ('/airflow/vs/0' in resources - and '/wind/strength/vs/0' not in resources) + return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources def _has_option_token(prefix): return lambda rep, resources: ( - _is_legacy_board(resources) and _option_token(rep, prefix) is not None) + is_legacy_board(resources) and _option_token(rep, prefix) is not None) def _option_token_on(prefix): @@ -322,14 +321,20 @@ def _humidity(rep): (51% observed, matching what the same unit's cloud integration reported at that moment), then zeroes the field and switches Air monitoring back off by itself. So 0 reads as "not measuring" and is reported as unknown rather than - as 0% humidity, which would poison long-term history -- which is also why - the boards that do have fivepercentHumidity read a permanent 0 here. + as 0% humidity, which would poison long-term history. + + That zero-as-"not measuring" carve-out is specific to the ARTIK051 + fallback field's hardware quirk -- every other board's fivepercentHumidity + has never been documented getting stuck at zero, and collapsing a + genuine 0% reading there to unknown is a regression, not a safeguard + (issue #160). So fivepercentHumidity passes 0 through unchanged; only the + humidity fallback applies the zero-collapse. """ - for field in ('x.com.samsung.da.fivepercentHumidity', - 'x.com.samsung.da.humidity'): - if field in rep: - value = _num(rep[field]) - return value if value else None + if 'x.com.samsung.da.fivepercentHumidity' in rep: + return _num(rep['x.com.samsung.da.fivepercentHumidity']) + if 'x.com.samsung.da.humidity' in rep: + value = _num(rep['x.com.samsung.da.humidity']) + return value if value else None return None @@ -422,13 +427,13 @@ CLIMATE = Capability( icon='mdi:led-on', entity_category='config'), # Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100). # Single-token option_write; a full options RMW reverts on ARTIK051_PRAC. - # Gated off the legacy ARTIK051 board generation (see _is_legacy_board): + # Gated off the legacy ARTIK051 board generation (see is_legacy_board): # that generation's own Volume_ token is already modeled as the # buzzer_volume Number below, and both reading the same options[] slot # into two entities would be redundant. SwitchDesc(key='beep', rep_fn=_beep_on, exists_fn=lambda rep, resources: ( - not _is_legacy_board(resources) + not is_legacy_board(resources) and _option_token(rep, 'Volume') is not None), write_fn=_beep_write, icon='mdi:volume-high', entity_category='config'), @@ -438,7 +443,7 @@ CLIMATE = Capability( # Sleep_ token is already the good_sleep Number below. NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value, exists_fn=lambda rep, resources: ( - not _is_legacy_board(resources) + not is_legacy_board(resources) and _option_token(rep, 'Sleep') is not None), write_fn=_tropical_night_write, native_min=0, native_max=16, step=1, @@ -783,6 +788,19 @@ _AC_IGNORED = [ # topology -- indoor/outdoor unit pairing, per-unit serials, MCU info. # Commissioning-time plumbing, not user-actionable appliance state. '/sac/installationinfo/vs/0', + # Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing + # *and* a wall-mounted indoor unit over one shared local IP, e.g. + # TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id + # list, same "remote device ids, not user-actionable locally" role as + # /remotedeviceinfo/vs/0 above. This integration talks to whichever + # single local endpoint the config entry was set up against; the + # second indoor unit isn't independently reachable through this + # resource (or any other in the dump) -- it would need its own local + # DTLS session/IP, which SmartThings pairing doesn't expose here. + '/subdevices/vs/0', + # Undocumented single int (runningMode: 0 on every dump seen), no + # supported-values list to interpret it against -- 'don't guess'. + '/runn/vs/0', ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index ac60523..4ed9bab 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -91,12 +91,25 @@ def _ml_to_l(v): def _active_alarm_codes(items): - """Join active alarm codes; skip retained rows Samsung leaves as Deleted. + """Join active alarm codes; skip retained rows Samsung leaves as Deleted, + and any code ending in '_OFF'. Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the condition clears (see WD7000B diagnostics). Surface only live alarms so - HA doesn't stick on a stale ErrorCode. Range-hood has its own stricter - helper that also drops ErrorCode_OFF. + HA doesn't stick on a stale ErrorCode. + + Samsung pre-populates this array with one row per alarm *type* the board + supports, each carrying its own '_OFF' placeholder code when that + alarm isn't firing -- confirmed across independent device families + (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF, + AC_V_0002_OFF all appear in fixtures with no corresponding active + condition). An alarm that's actually firing instead reports a plain, + unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's + AC dump has both a FilterAlarm_OFF placeholder and shows what a live + filter alert looks like: code 'FilterAlarm' (no suffix), state + 'Created'. Range-hood previously special-cased only the literal + 'ErrorCode_OFF' string in its own stricter helper; this generalizes + the same rule to the whole '_OFF' suffix convention. """ if not items or not isinstance(items, list): return 'none' @@ -105,6 +118,7 @@ def _active_alarm_codes(items): for i in items if i.get('x.com.samsung.da.code') and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted' + and not str(i.get('x.com.samsung.da.code', '')).lower().endswith('_off') ] return ', '.join(codes) if codes else 'none' diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 3171475..f931798 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -53,6 +53,10 @@ IGNORED: list[Capability] = [ Capability(href='/setting/vs/0'), # supported/selected UI language Capability(href='/timezone/vs/0'), # redundant with HA's own timezone Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata + # Resource-monitoring poll-interval config (a bare minPeriod in + # milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport + # plumbing, not appliance state. + Capability(href='/rm/control/vs/0'), # Demand Response Load Control — utility-company grid signals; requires # cloud registration with a utility program we don't support locally. @@ -107,6 +111,11 @@ IGNORED: list[Capability] = [ # exposed by dryer.DRYER_COURSE at /course/vs/0 # (x.com.samsung.da.st.dryerMode is "Table_03_Course_"). Capability(href='/st/dryercourse/vs/0'), + # AirDresser counterpart of the above (issue #157): read only for its + # courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no + # entity of its own -- same "no entity, just the table id" role as + # /st/washercourse/vs/0 and /st/dryercourse/vs/0. + Capability(href='/st/airdressercourse/vs/0'), # Empty on every washer dump seen so far. Capability(href='/wm/welcomemsg/vs/0'), # User-saved custom course slots (F1-FA). No controllable/observable diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 7dcb7f5..7323c60 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -21,7 +21,12 @@ different from an oven, and defined fresh here: * Lamp: this family's option-array token is bare 'Lamp' (issue #137's 'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on the combi dump (issue #121), so it's gated with exists_fn rather than - assumed universal like oven.py's lamp switch. + assumed universal like oven.py's lamp switch. Issue #137's dump only + ever showed 'Off', so 'On' was a guess at the paired value; issue #152's + ME7500D dump is the first to show a real non-Off value, and it's 'High' + (a brightness level, not literally 'On') -- the switch now treats any + non-Off/non-None value as "on" for reads, and writes back 'High'/'Off' + (the two confirmed tokens) rather than the never-confirmed 'On'. Note: cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write @@ -107,8 +112,21 @@ def _power_level_watts(v): return int_or_none(s) +def _cooking_mode_options(resources): + """Live mode list from the device's own /mode/vs/0 supportedModes when + it reports one (both known dumps do); the union-of-all-dumps + _MICROWAVE_MODES guess otherwise. Same live-first, static-fallback + pattern as oven._oven_mode_options -- a fixed list here would offer + users modes their own unit doesn't have (issue #152's ME7500D reports + only 4 of _MICROWAVE_MODES' 11).""" + rep = resources.get('/mode/vs/0') or {} + live = rep.get('x.com.samsung.da.supportedModes') + return list(live) if live else list(_MICROWAVE_MODES) + + def _mode_write(p, rep, href=None): - if p not in _MICROWAVE_MODES: + valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES + if p not in valid: return None return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} @@ -132,8 +150,12 @@ def _lamp_write(p, rep, href=None): return None if not rep.get('x.com.samsung.da.options'): return None + # 'High' and 'Off' are the two tokens actually confirmed on live dumps + # (issues #137/#152) -- 'On' has never been observed and the device + # likely doesn't recognize it (see module docstring). + token = 'High' if p == 'On' else 'Off' return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': option_write('Lamp', p), + 'x.com.samsung.da.options': option_write('Lamp', token), } @@ -178,7 +200,7 @@ MICROWAVE_MODE = Capability( # SelectDesc first — test_microwave_mode_options_nonempty uses entities[0] SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes', icon='mdi:tune', - options=_MICROWAVE_MODES, + options=_cooking_mode_options, value_fn=lambda v: v[0] if v else None, write_fn=_mode_write), SwitchDesc(key='sound', field='x.com.samsung.da.options', @@ -189,7 +211,7 @@ MICROWAVE_MODE = Capability( SwitchDesc(key='lamp', field='x.com.samsung.da.options', icon='mdi:track-light', exists_fn=_lamp_exists, - value_fn=lambda opts: option_value(opts, 'Lamp') == 'On', + value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'), write_fn=_lamp_write), ), ) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 4b47a38..0a28cc9 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -51,6 +51,14 @@ SETPOINT_STEP_F = 5 # Mode options seen on NV7000BS-class. No dump exists so this list is inferred # from Samsung documentation and firmware observations. The firmware will # reject unknown modes; missing entries here are a coverage gap, not a bug. +# +# This is a fallback only, used when a device's own /mode/vs/0 doesn't report +# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/ +# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports +# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean +# in its own supportedModes; those are read live rather than added here, per +# the adding-device-support skill's preference for device-reported option +# lists over hardcoded ones. _OVEN_MODES = ( 'NoOperation', 'Bake', @@ -173,8 +181,20 @@ def _cook_time_write(p, rep, href=None): } +def _oven_mode_options(resources): + """Live mode list from the device's own /mode/vs/0 supportedModes when + it reports one; the NV7000BS-era _OVEN_MODES guess otherwise. Mirrors + laundry.py's options_field pattern (buzzer_sound/finish_sound), but + needs the callable form rather than options_field because a static + fallback has to kick in when the device's own field is absent.""" + rep = resources.get('/mode/vs/0') or {} + live = rep.get('x.com.samsung.da.supportedModes') + return list(live) if live else list(_OVEN_MODES) + + def _oven_mode_write(p, rep, href=None): - if p not in _OVEN_MODES: + valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES + if p not in valid: return None return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} @@ -351,7 +371,7 @@ OVEN_MODE = Capability( # SelectDesc first — test_oven_mode_options_nonempty uses entities[0] SelectDesc(key='oven_mode', field='x.com.samsung.da.modes', icon='mdi:tune', - options=_OVEN_MODES, + options=_oven_mode_options, value_fn=lambda v: v[0] if v else None, write_fn=_oven_mode_write), SwitchDesc(key='lamp', field='x.com.samsung.da.options', diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 18cbaf9..2e2cbd1 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -73,7 +73,15 @@ def discover( registry: dict[str, list[Capability]], pattern_caps: Iterable[Capability] = (), log: Optional[Callable[[str], None]] = None, + tier_log: Optional[Callable[[str, str], None]] = None, ) -> list[BoundEntity]: + """`tier_log(href, poll_tier)` fires for every href a capability actually + matches, even a no-entity "coverage-only" capability (see COVERAGE lists + in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows. + Callers that need a href's poll cadence (the coordinator's hot/warm + sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a + coverage-only capability's `poll_tier` would otherwise be silently + dropped since it never appears in `bound`.""" out: list[BoundEntity] = [] for href, rep in resources.items(): @@ -91,6 +99,8 @@ def discover( inst = instance_suffix(href) out.extend(_bind(cap, href, inst, _instance_name(cap, rep))) matched = True + if tier_log is not None: + tier_log(href, cap.poll_tier) if matched: continue @@ -109,6 +119,8 @@ def discover( segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs))) matched = True + if tier_log is not None: + tier_log(href, cap.poll_tier) break if not matched and not caps and log is not None: diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b311177..e92e5ec 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -120,7 +120,8 @@ "state_attributes": { "fan_mode": { "state": { - "turbo": "Turbo" + "turbo": "Turbo", + "max": "Max" } }, "preset_mode": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index fdccfc1..4e53716 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -120,7 +120,8 @@ "state_attributes": { "fan_mode": { "state": { - "turbo": "Turbo" + "turbo": "Turbo", + "max": "Max" } }, "preset_mode": { diff --git a/tests/fixtures/air_dresser_device.json b/tests/fixtures/air_dresser_device.json new file mode 100644 index 0000000..23847c2 --- /dev/null +++ b/tests/fixtures/air_dresser_device.json @@ -0,0 +1,209 @@ +{ + "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": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "VN" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015E", + "UpdateAllow_NotAllowed", + "Course_01", + "SteamPush_None", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_58", + "DrumCleanProposal_30", + "SpecialFunction_4", + "AvailableDelayTime_0", + "ProgressTimeSet_AA01E0AC00B4B20690", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "GMT_12", + "Alarm_Off", + "SilentSet_0F0FF0F0F0F0F0F0F0F00F0F0F0FF0F0F00FF0", + "Silent_Off", + "WrinklePreventSet_0F0F0F0F0F0F0F0FF0F00F0F0F0F0F0F0F0FF0", + "DelayEndSet_0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "OneTimeCourse_761F00200000000000000000000000", + "DownloadCourseList_090A0B12100C140D0F", + "EnergyLevelSet_0502020104030204050405020303010302010305", + "MostUsed_01", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2024-01-01T00:00:00", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "001020403051A1B1C0708" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "78200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1785272400", + "x.com.samsung.da.cumulativeDateUTC": "1785240000" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON_DF8600T/DC92-02656A_0018", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02672A230710(E431)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20060507,20072702", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "REDACTED", + "machineStates": "REDACTED", + "jobStates": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "00:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "00:39: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", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "10", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/air_dresser_tp2_20_device.json b/tests/fixtures/air_dresser_tp2_20_device.json new file mode 100644 index 0000000..8e96d3c --- /dev/null +++ b/tests/fixtures/air_dresser_tp2_20_device.json @@ -0,0 +1,282 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airdresseroption/sanitize/vs/0", + "rep": { + "x.com.samsung.da.sanitize": "Off", + "x.com.samsung.da.supportedSanitize": [ + "On", + "Off" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3171000000", + "x.com.samsung.da.countryCode": "KR" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015E", + "UpdateAllow_NotAllowed", + "Course_22", + "AiOption_On", + "SteamPush_None", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "FilterUsage_384019D5", + "WashingTimes_6", + "DrumCleanProposal_30", + "ProgressTimeSet_AA01A4AC00F0B20690", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "WeatherPush_Off", + "GMT_12", + "Alarm_Off", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0", + "DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F00F0F0FF0F0F0F0F00F0F", + "EnergyLevelSet_0502010102010202030303010401040505050503030502040503030303030405", + "UsagesDB_ok", + "DrumCleanLog_2023-04-02T08:08:55|2023-10-02T03:38:19|2024-05-26T03:25:41|2024-10-01T01:07:20|2026-06-16T02:23:29" + ], + "x.com.samsung.da.supportedOptions": [ + "12261062361020E61020961021261020C61021E61020B61021061020A61061461021361061661022462062562062F62062062040F62042761023061021561021A61021B61021C61022A61022B61022C61022D61022E6102076102086102" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "Off" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "63600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784404800", + "x.com.samsung.da.cumulativeDateUTC": "1784372400" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF9500A/DC92-02888A_0002", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "A00", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02673A250416(F822)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "21020115,23030652", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "19111852,FFFFFFFF" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "REDACTED", + "machineStates": "REDACTED", + "jobStates": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "00:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "00:39: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", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/st/airdressercourse/vs/0", + "rep": { + "x.com.samsung.da.st.airdresserMode": "Table_00_Course_22", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_22230C10252F0F0E09121E0B0A14131624202730151A1B1C2A2B2C2D2E0708", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_22270F" + } + }, + { + "href": "/wm/jobbeginingstatus/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": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "true" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/air_purifier_vtww_device.json b/tests/fixtures/air_purifier_vtww_device.json new file mode 100644 index 0000000..e3c7df1 --- /dev/null +++ b/tests/fixtures/air_purifier_vtww_device.json @@ -0,0 +1,310 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingActivationState": "Off", + "x.com.samsung.da.periodicSensingInterval": "600", + "x.com.samsung.da.startSensingOnce": "On", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785195000", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.autoExeState": "Off", + "x.com.samsung.da.supportedAutoExeState": [ + "Off", + "Airpurify", + "Alarm" + ], + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "15002100" + } + }, + { + "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": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "0000000000000000000000000000", + "x.com.samsung.da.id": "VTL", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3017000000", + "x.com.samsung.da.airconOptionList": [ + "WELCOMECARE", + "AI_PURIFY" + ], + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "href": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.startTime": "14:00:00", + "x.com.samsung.da.endTime": "22:00:00", + "x.com.samsung.da.visible": "true", + "x.com.samsung.da.useTimeSetting": "true", + "x.com.samsung.da.functionState": "true" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "196696", + "x.com.samsung.da.cumulativeDate": "1785245199", + "x.com.samsung.da.cumulativeDateUTC": "1785212799", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/filter/hepafilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "0", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "8760", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/humidity/0", + "rep": {} + }, + { + "href": "/humidity/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "A-VTWW-TP2-21-COMMON|10231241|70000535001511EC0505090201000000", + "x.com.samsung.da.description": "A-VTWW-TP2-21-COMMON", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "101", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "02308A260310", + "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": "21063003,21063002", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "CountOfSlave_0", + "SlavePower01_NotSupported", + "Pollution_Off", + "OptionCode_23418", + "SmartSleep_Off" + ] + } + }, + { + "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": "A-VTWW-TP2-21-COMMON", + "versions": [ + "12260310" + ], + "visVersion": "260310" + }, + { + "type": "Micom", + "modelId": "09501023124110231341", + "versions": [ + "21063003", + "21063002" + ], + "visVersion": "210630" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "7", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + } + ], + "x.com.samsung.da.total": "5" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "87", + "x.com.samsung.da.supportedModes": [ + "87", + "89", + "90", + "91" + ], + "x.com.samsung.da.modesName": [ + "SMART", + "MAX", + "WINDFREE", + "Sleep" + ] + } + } + ] +} diff --git a/tests/fixtures/airconditioner_fac_bora_device.json b/tests/fixtures/airconditioner_fac_bora_device.json new file mode 100644 index 0000000..f1deb08 --- /dev/null +++ b/tests/fixtures/airconditioner_fac_bora_device.json @@ -0,0 +1,446 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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": "2024-01-01T00:00:00" + }, + { + "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": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C0000404B0C000000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2824500000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "AI_2.0", + "DeviceTypeMaster", + "SingleCommand_1" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 100.0, + "power": 65278.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "100.000000", + "x.com.samsung.da.instantaneousPower": "65278.000000", + "x.com.samsung.da.usageThreshold": "0.000000", + "x.com.samsung.da.cumulativePower": "1278818", + "x.com.samsung.da.cumulativeDate": "1785243600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "112", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "112", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "73" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "000", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "02337A260424", + "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": "2102240021022200", + "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": "2103300110000300" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Speed" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "AIComfort" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Blooming_0", + "OnTimer_0", + "OffTimer_0", + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_270", + "welcomecare_Off", + "Panel_Close", + "Weather_Off", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "Display_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "OptionCode_529", + "ExtendOptionCode_16975", + "RacInfo_First", + "RacInfo_None_Second", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_Off", + "DurationOn_0", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "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": "/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-KR-TP2-21-AF9X00", + "versions": [ + "10260424" + ], + "visVersion": "260424" + }, + { + "type": "Micom", + "modelId": "04511023304110232941", + "versions": [ + "21022400", + "21022200" + ], + "visVersion": "210224" + }, + { + "type": "Micom", + "modelId": "04511022974110229941", + "versions": [ + "21033001", + "10000300" + ], + "visVersion": "210330" + }, + { + "type": "Micom", + "modelId": "045110230741FFFFFFFF", + "versions": [ + "22050300", + "FFFFFFFF" + ], + "visVersion": "220503" + } + ] + } + }, + { + "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": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/runn/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/subdevices/vs/0", + "rep": { + "x.com.samsung.da.subdeviceIdList": "REDACTED" + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 31.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "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": "31.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "NotSupported", + "x.com.samsung.da.supportedModes": [ + "NotSupported" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} diff --git a/tests/fixtures/airconditioner_tp1x_rac_01001_device.json b/tests/fixtures/airconditioner_tp1x_rac_01001_device.json new file mode 100644 index 0000000..a1c2001 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_rac_01001_device.json @@ -0,0 +1,525 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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": "/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": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DBF00A0012C3F61024804000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "1150000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "AI_RAC_KOREA_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "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": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 338.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "338.000000", + "x.com.samsung.da.cumulativePower": "1362848", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "2", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "42" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10250041|6001051C001711014E00482200912000", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-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": "AR1", + "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": "02646A260327", + "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": "102500A23012700", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "102472A22120800,102528A10000100", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Nano", + "NanoSleep" + ] + } + }, + { + "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": [ + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_280", + "welcomecare_Off", + "OutdoorConnection_Connected", + "OutdoorTemp_86", + "CoolCapa_28", + "WarmCapa_0", + "Volume_100", + "StopAutoClean_Set", + "Autoclean_On", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "WashAlarm_Off", + "OptionCode_56440", + "ExtendOptionCode_246479", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_115", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "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", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "ARA-WW-TP1-24-ARXX00", + "versions": [ + "11260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "045210250041FFFFFFFF", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + }, + { + "type": "Micom", + "modelId": "04521024724110252841", + "versions": [ + "22120800", + "10000100" + ], + "visVersion": "221208" + } + ] + } + }, + { + "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": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotedeviceinfo/vs/0", + "rep": { + "x.com.samsung.da.didList": "" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DFFFFFFFF101E121EFFFFFFFFFFFF01000000012F0100000F000000001C00", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 28.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 25.0 + } + }, + { + "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": "25.0", + "x.com.samsung.da.current": "28.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "32", + "x.com.samsung.da.supportedModes": [ + "0", + "31", + "32", + "33", + "34", + "35" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "MAX" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED", + "connectedApSsid": "REDACTED" + } + } + ] +} diff --git a/tests/fixtures/golden/air_dresser.json b/tests/fixtures/golden/air_dresser.json new file mode 100644 index 0000000..fdc4b43 --- /dev/null +++ b/tests/fixtures/golden/air_dresser.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/fixtures/golden/air_dresser_tp2_20.json b/tests/fixtures/golden/air_dresser_tp2_20.json new file mode 100644 index 0000000..a935010 --- /dev/null +++ b/tests/fixtures/golden/air_dresser_tp2_20.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "sanitize", + "wrinkle_prevent" + ] +} diff --git a/tests/fixtures/golden/air_purifier_vtww.json b/tests/fixtures/golden/air_purifier_vtww.json new file mode 100644 index 0000000..9970ed3 --- /dev/null +++ b/tests/fixtures/golden/air_purifier_vtww.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "alarm_code", + "clean_level", + "device_active", + "dust", + "energy_kwh", + "fine_dust", + "firmware_update", + "hepa_filter_status", + "hepa_filter_usage", + "odor", + "power_switch", + "super_fine_dust", + "wind_strength_fan" + ] +} diff --git a/tests/fixtures/golden/airconditioner_fac_bora.json b/tests/fixtures/golden/airconditioner_fac_bora.json new file mode 100644 index 0000000..aca70e7 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_fac_bora.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_threshold", + "air_filter_usage", + "air_filter_usage_hours", + "alarm_code", + "auto_clean", + "beep", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "firmware_update", + "humidity", + "power_energy_kwh", + "power_watts", + "tropical_night_mode" + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json new file mode 100644 index 0000000..a138056 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_filter_usage_hours", + "alarm_code", + "auto_clean", + "beep", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "power_watts", + "tropical_night_mode" + ] +} diff --git a/tests/fixtures/golden/microwave_me7500d_lamp_high.json b/tests/fixtures/golden/microwave_me7500d_lamp_high.json new file mode 100644 index 0000000..40df21d --- /dev/null +++ b/tests/fixtures/golden/microwave_me7500d_lamp_high.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "cavity_state", + "child_lock", + "cloud_connected", + "cook_time", + "cooking_mode", + "cycle_active", + "door_open", + "energy_kwh", + "fan", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "operation_time_minutes", + "power_level", + "progress_percentage", + "sound" + ] +} diff --git a/tests/fixtures/golden/range_ne63a6511.json b/tests/fixtures/golden/range_ne63a6511.json new file mode 100644 index 0000000..a7ef5a5 --- /dev/null +++ b/tests/fixtures/golden/range_ne63a6511.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_running_state", + "current_temp_c", + "cycle_active", + "door_open", + "fast_preheat", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "natural_steam", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound", + "warming_center_state" + ] +} diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json new file mode 100644 index 0000000..dbd457a --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_eu.json @@ -0,0 +1,31 @@ +{ + "state_keys": [ + "ai_energy_level", + "alarm_code", + "auto_door_opener", + "brightness_level", + "cabinet_light_dim", + "cabinet_light_switch", + "cooler_setpoint", + "cooler_temperature", + "day_brightness", + "door_alert", + "door_cooler_open", + "door_freezer_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "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/microwave_me7500d_lamp_high_device.json b/tests/fixtures/microwave_me7500d_lamp_high_device.json new file mode 100644 index 0000000..3ac621a --- /dev/null +++ b/tests/fixtures/microwave_me7500d_lamp_high_device.json @@ -0,0 +1,269 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": [ + "x.com.samsung.da.connected" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "103700", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/hood/fanspeed/vs/0", + "rep": { + "x.com.samsung.da.hood.fanSpeed": "0", + "x.com.samsung.da.hood.supportedFanSpeed": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.hood.settableMaxFanSpeed": "4", + "x.com.samsung.da.hood.settableMinFanSpeed": "0", + "rt": [ + "x.com.samsung.da.hood.fanSpeed" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40475141|50040000011811000A00000000000000", + "x.com.samsung.da.description": "ME7500D-/AA2", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "24111400", + "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": "04751A23121900", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "04754B23120800", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "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": "KM5", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "NoOperation", + "MicroWave", + "Autocook", + "KeepWarm" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_ME7500D-/AA2", + "TimeAutoSync_On", + "weight_LBS", + "TimeSystem_12", + "Sound_Off", + "RemindBeep_Off", + "FilterRemind_Off", + "Lamp_High" + ], + "x.com.samsung.da.defaultMode": "MicroWave", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "0", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "flashingProgress": "", + "otnStatus": "None", + "otnList": [ + { + "type": "WIFI", + "modelId": "AKS-WW-TP1-23-MICROWAVE-OTR", + "versions": [ + "40241114" + ], + "visVersion": "241114" + }, + { + "type": "Micom", + "modelId": "074240475141FFFFFFFF", + "versions": [ + "23121900", + "FFFFFFFF" + ], + "visVersion": "231219" + }, + { + "type": "Micom", + "modelId": "074240475442FFFFFFFF", + "versions": [ + "23120800", + "FFFFFFFF" + ], + "visVersion": "231208" + } + ] + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.recipe": "00000000000000", + "x.com.samsung.da.powerLevel": "0", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/New_York", + "offset": "-04:00", + "DST": "ON" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED" + } + } + ] +} diff --git a/tests/fixtures/range_ne63a6511_device.json b/tests/fixtures/range_ne63a6511_device.json new file mode 100644 index 0000000..973891b --- /dev/null +++ b/tests/fixtures/range_ne63a6511_device.json @@ -0,0 +1,200 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/cooktopmonitoring/vs/0", + "rep": { + "x.com.samsung.da.warmingCenterState": "Off", + "x.com.samsung.da.cooktopMonitoring": "0", + "x.com.samsung.da.cooktopRunningState": "Ready", + "supportedHoodLampStateList": [ + "Run", + "Ready" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.lock": "Unlock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NE6516A-/AA0", + "SettingPossible_0", + "UpperLamp_Off", + "Sound_Off", + "AdjustingTemp_0", + "Sabbath_Off", + "EnergySaving_On", + "BurnerOnAlert_Off" + ], + "x.com.samsung.da.defaultMode": "ConvectionBake", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"},{\"mode\":\"ConvectionRoast\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"},{\"mode\":\"AirFryer\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"175\",\"tempMaxC\":\"260\",\"tempDefaultC\":\"220\",\"tempListLengthC\":\"0\",\"tempMinF\":\"350\",\"tempMaxF\":\"500\",\"tempDefaultF\":\"425\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"}]", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "0", + "x.com.samsung.da.current": "175", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Fahrenheit" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json new file mode 100644 index 0000000..a6f3b48 --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_eu_device.json @@ -0,0 +1,470 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/enhanced/vs/0", + "rep": { + "light.control.status": "Off", + "level.brightness.daytime": "100", + "level.brightness.nighttime": "100", + "night.starttime": "2025-08-08T19:00:00", + "night.duration.minute": "540", + "timezone.offset": "+02:00", + "rt": [ + "x.com.samsung.da.light.enhanced" + ], + "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": "On", + "rt": [ + "x.com.samsung.da.cabinetlight" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "" + } + }, + { + "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": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close", + "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": "/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": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1", + "2" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "281", + "x.com.samsung.da.instantaneousPower": "48", + "x.com.samsung.da.cumulativePower": "216620", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00175941|00050126001811344100000020090000", + "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": "RB2", + "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": "24070109, FFFFFFFF, 24062000, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "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" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "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": [] + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off", + "rt": [ + "x.com.samsung.da.fridge" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/rm/control/vs/0", + "rep": { + "minPeriod": "9000" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 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": "/settings/sound/alert/door/vs/0", + "rep": { + "alert.door": "1", + "supportedAlert.door": [ + "1", + "2", + "3", + "4" + ], + "rt": [ + "x.com.samsung.alert.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.device.sound": "Off", + "rt": [ + "x.com.samsung.da.lockstatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "temperature": 6.0, + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "temperature": -17.0, + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "temperature": 6.0, + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "temperature": -17.0, + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "-17", + "x.com.samsung.da.current": "-17", + "x.com.samsung.da.maximum": "-15", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "6", + "x.com.samsung.da.current": "6", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Europe/Rome", + "offset": "+02:00", + "DST": "ON" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED", + "connectedApSsid": "SPRDFL24" + } + } + ] +} diff --git a/tests/test_air_dresser_capabilities.py b/tests/test_air_dresser_capabilities.py new file mode 100644 index 0000000..5a4e838 --- /dev/null +++ b/tests/test_air_dresser_capabilities.py @@ -0,0 +1,70 @@ +"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162).""" +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 tests.conftest import _load_device + + +def _air_dresser(): + resources = _load_device('air_dresser') + 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(): + reg, resources = _air_dresser() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_air_dresser_registry(): + reg, _ = _air_dresser() + assert reg is not None and reg.name == 'air_dresser' + + +def test_no_unbound_hrefs(): + reg, resources = _air_dresser() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_course_select_present_and_reads_current_selection(): + """This was the actual reported gap: /course/vs/0 was previously + entirely unbound, so no cycle/mode select existed at all.""" + state = _state() + assert state['cycle'] == '01' + + +def test_course_options_derived_from_supported_options_fallback(): + """No /wm/editcourse/vs/0 on this board at all, so the option list must + come from laundry.cycle_options' supportedOptions decode rather than + editCourseList -- confirmed distinct codes, current selection included.""" + from custom_components.localthings.registry.capabilities.laundry import cycle_options + _, resources = _air_dresser() + codes = cycle_options(resources) + assert codes == ['01', '02', '04', '03', '05', '1A', '1B', '1C', '07', '08'] + + +def test_wrinkle_prevent_present_dry_level_fields_absent(): + """AIR_DRESSER_SETTINGS only binds wrinkle_prevent -- dryLevel/dryTime/ + dryerType are dryer-only fields this device never reports, so they + should not appear as always-empty entities.""" + state = _state() + assert state['wrinkle_prevent'] is False + for key in ('dry_level', 'dry_time', 'dryer_type'): + assert key not in state, key + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'child_lock', 'remote_control', 'cycle', + 'machine_state', 'progress', 'progress_percentage', 'finish_time', + 'completion_minutes', 'delay_start_hours', 'diagnosis_status', + 'job_beginning_status', 'wrinkle_prevent', 'energy_kwh', + ): + assert key in state, key diff --git a/tests/test_air_dresser_tp2_20_capabilities.py b/tests/test_air_dresser_tp2_20_capabilities.py new file mode 100644 index 0000000..2d3ef00 --- /dev/null +++ b/tests/test_air_dresser_tp2_20_capabilities.py @@ -0,0 +1,82 @@ +"""Tests for the DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157). + +A different board generation than issue #162's DA_DF_A51_20_COMMON, routed +into the same air_dresser registry (both carry the '_DF_' modelNum token). +Exercises the two things this board does differently: a populated +/wm/editcourse/vs/0 (so cycle_options() never needs the supportedOptions +fallback #162 relies on) and the new /airdresseroption/sanitize/vs/0 +capability #162's dump doesn't report at all. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _air_dresser(): + resources = _load_device('air_dresser_tp2_20') + 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(): + reg, resources = _air_dresser() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_air_dresser_registry(): + reg, _ = _air_dresser() + assert reg is not None and reg.name == 'air_dresser' + + +def test_no_unbound_hrefs(): + """Confirms /st/airdressercourse/vs/0 and /airdresseroption/sanitize/vs/0 + -- the two hrefs this board reports that #162's dump doesn't -- are + covered (ignored.py and AIR_DRESSER_SANITIZE respectively).""" + reg, resources = _air_dresser() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_course_options_come_from_edit_course_list_not_the_fallback(): + """Unlike issue #162's board, this one populates /wm/editcourse/vs/0 + directly, so cycle_options() should never reach the + supportedOptions-decode fallback.""" + from custom_components.localthings.registry.capabilities.laundry import cycle_options + _, resources = _air_dresser() + codes = cycle_options(resources) + assert codes[:3] == ['22', '23', '0C'] + assert codes == cycle_options({'/wm/editcourse/vs/0': resources['/wm/editcourse/vs/0']}) + + +def test_course_select_reads_current_selection(): + state = _state() + assert state['cycle'] == '22' + + +def test_course_translation_key_falls_back_to_cycle_for_unidentified_table(): + """Table_00's course codes aren't identified yet, so the select's + translation_key resolves to the generic 'cycle' catalog entry rather + than a table-specific one that doesn't exist.""" + _, resources = _air_dresser() + desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0] + assert desc.translation_key(resources) == 'cycle' + + +def test_sanitize_present_and_toggles(): + state = _state() + assert state['sanitize'] is False + + desc = next( + e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities + if e.key == 'sanitize' + ) + path, body = desc.write_fn('On', {}) + assert path == ['airdresseroption', 'sanitize', 'vs', '0'] + assert body == {'x.com.samsung.da.sanitize': 'On'} + assert desc.write_fn('Sparkle', {}) is None diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py new file mode 100644 index 0000000..e03ad61 --- /dev/null +++ b/tests/test_air_purifier_vtww_fan.py @@ -0,0 +1,128 @@ +"""HA fan-entity mapping tests for the A-VTWW-TP2-21-COMMON BESPOKE Cube Air +(issue #151). + +This board's /wind/strength/vs/0 reports numeric wind-strength codes +("87"/"89"/"90"/"91") with a separate modesName array ("SMART"/"MAX"/ +"WINDFREE"/"Sleep") giving the actual names, unlike the TP1X_DA-AC-AIR +family's /mode/vs/0 (issue #130) where supportedModes IS the name list +already. LocalThingsAirPurifierFan._label_for_code resolves both shapes +without a per-model map. +""" +from custom_components.localthings.fan import LocalThingsAirPurifierFan +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import FanDesc + +from tests.conftest import _load_device + + +class _FakeCoordinator: + device_serial = 'TEST-VTWW-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _resources(): + return _load_device('air_purifier_vtww') + + +def _reg(resources): + info = resources['/information/vs/0'] + return for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + + +def _entity(resources, coordinator=None): + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + fan_bound = next( + item for item in bound + if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH + ) + return LocalThingsAirPurifierFan(coordinator or _FakeCoordinator(resources), fan_bound) + + +def test_resolves_to_air_purifier_registry(): + assert _reg(_resources()).name == 'air_purifier' + + +def test_no_unbound_hrefs(): + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_preset_modes_come_from_modes_name_not_the_raw_codes(): + entity = _entity(_resources()) + assert entity.preset_modes == ['smart', 'max', 'windfree', 'sleep'] + + +def test_preset_mode_reads_the_current_code_via_modes_name(): + """Fixture's current mode is '87' -> modesName[0] 'SMART'.""" + entity = _entity(_resources()) + assert entity.preset_mode == 'smart' + + +async def test_set_preset_mode_writes_back_the_raw_code(): + resources = _resources() + coordinator = _FakeCoordinator(resources) + entity = _entity(resources, coordinator) + + await entity.async_set_preset_mode('windfree') + + assert coordinator.commands[-1][1] == ('mode', '90') + + +def test_is_on_reads_vendor_power(): + entity = _entity(_resources()) + assert entity.is_on is False + + +def test_wind_strength_fan_key_does_not_collide_with_mode_fan(): + """WIND_STRENGTH_FAN and FAN both live in this registry and both are + FanDesc-typed; BoundEntity's unique_id is built from key alone (entity.py's + _key), not href, so a shared key would silently shadow one entity if a + board ever bound both (see AIRFLOW_GENERIC's own comment on this exact + hazard). No real dump reports both hrefs today, but the keys must stay + distinct regardless.""" + from custom_components.localthings.registry.capabilities import air_purifier + + fan_keys = { + entity.key + for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN) + for entity in cap.entities + } + assert fan_keys == {'fan', 'wind_strength_fan'} + + +def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities(): + """Synthetic combination (no real dump reports both hrefs) proving the + two FanDescs don't shadow each other in flatten()'s key-based state dict + even if a future board did report both.""" + from custom_components.localthings.registry.adapter import flatten + + resources = _resources() + resources['/mode/vs/0'] = { + 'x.com.samsung.da.modes': ['Smart'], + 'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'], + } + reg = _reg(resources) + unbound = [] + bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + state = flatten(bound, resources) + assert state['fan'] == 'Smart' + assert state['wind_strength_fan'] == '87' diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 2e71e89..d6b4b98 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -24,7 +24,8 @@ from custom_components.localthings.registry.by_type import ( airconditioner, for_device_by_model, ) from custom_components.localthings.registry.capabilities.airconditioner import ( - HREF_AIRFLOW, _option_number_write, _option_switch_write, + HREF_AIRFLOW, HREF_WIND_STRENGTH, _option_number_write, + _option_switch_write, is_legacy_board, ) from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc @@ -119,6 +120,22 @@ def test_token_entities_stay_off_newer_boards(): assert key not in state, key +def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board(): + """issue #161: climate.py's _legacy_airflow() delegates to + capabilities/airconditioner.py's is_legacy_board() instead of + re-implementing the same presence/absence check, so the token entities + and the climate card's legacy read/write paths can't drift apart on + which board generation is in play.""" + legacy_resources = _load_device(FIXTURE) + assert is_legacy_board(legacy_resources) is True + assert _climate(legacy_resources)._legacy_airflow() == legacy_resources[HREF_AIRFLOW] + + newer_resources = _load_device('airconditioner_tp1x_rac') + assert is_legacy_board(newer_resources) is False + assert _climate(newer_resources)._legacy_airflow() == {} + assert HREF_WIND_STRENGTH in newer_resources + + def test_absent_token_yields_no_entity(): """The issue #136 unit of this same model reports no Spi token.""" resources = _load_device(FIXTURE) diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 15f1a47..282dd23 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -418,6 +418,61 @@ def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent(): assert desc.rep_fn({}) is None +def test_humidity_five_percent_field_passes_a_genuine_zero_through(): + """issue #160: fivepercentHumidity's zero-as-"not measuring" carve-out + (added in #146 to cover ARTIK051's plain humidity field) was + over-applied to fivepercentHumidity too, silently turning a real 0% + reading on every other AC board into unknown. Only the humidity + fallback field collapses 0 -- fivepercentHumidity's 0 is a real + reading.""" + desc = airconditioner.HUMIDITY.entities[0] + assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0 + + +# --------------------------------------------------------------------------- +# Wind-Free 2-in-1 (TP2X_FAC_BORA_21K, issues #150/#153): a floor-standing + +# wall-mounted indoor unit pair sharing one outdoor unit and one local IP. +# Reported "no climate entity is generated, only power" -- the device simply +# fell back to 'unknown' for lack of a '_FAC_' modelNum routing token; once +# routed, it binds against the exact same CLIMATE composite every other RAC +# family uses, zero unbound hrefs, no new capabilities needed beyond ignoring +# the two hrefs (/subdevices/vs/0, /runn/vs/0) unique to this board. +# --------------------------------------------------------------------------- + +def _ac_fac_bora(): + resources = _load_device('airconditioner_fac_bora') + 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 test_fac_bora_resolves_to_airconditioner_registry(): + reg, _ = _ac_fac_bora() + assert reg is not None and reg.name == 'airconditioner' + + +def test_fac_bora_no_unbound_hrefs(): + reg, resources = _ac_fac_bora() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_fac_bora_climate_entity_present(): + """The actual reported gap: only a power switch existed before, no + climate entity at all.""" + reg, resources = _ac_fac_bora() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + assert any(isinstance(item.desc, ClimateDesc) for item in bound) + + +def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): + assert '/subdevices/vs/0' in airconditioner._AC_IGNORED + assert '/runn/vs/0' in airconditioner._AC_IGNORED + + # --------------------------------------------------------------------------- # Additive entities layered on the ARTIK051_PRAC family on top of the upstream # registry: beep (Volume_* option), tropical night mode (Sleep_ option), diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py new file mode 100644 index 0000000..0b948b8 --- /dev/null +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -0,0 +1,134 @@ +"""TP1X_DA-AC-RAC-01001_0000 fan-strength codes (model AR07C9150HZN, issue +#155). + +Its /wind/strength/vs/0 reports supportedModes "0"/"31"/"32"/"33"/"34"/"35" +instead of the "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from +(every other AC fixture in this repo uses "0"-"4", some with a 6th "5" -- +see airconditioner_window_ac_device.json). Only "0" matched _DEVICE_TO_FAN, +so fan_modes silently dropped every speed but Auto. The fix reads the +device's own modesName labels (parallel-indexed with supportedModes) for +any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a +second numeric scale. +""" +from custom_components.localthings.climate import ( + LocalThingsClimate, _DEVICE_TO_FAN, _wind_strength_label, +) +from custom_components.localthings.registry import by_type +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc + +from tests.conftest import _load_device + +FIXTURE = 'airconditioner_tp1x_rac_01001' + + +class _FakeCoordinator: + device_serial = 'TEST-RAC-01001-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _climate(resources, coordinator=None): + info = resources['/information/vs/0'] + reg = by_type.for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) + return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound) + + +def test_wind_strength_label_reads_the_devices_own_modes_name(): + rep = { + 'x.com.samsung.da.supportedModes': ['0', '31', '32', '33', '34', '35'], + 'x.com.samsung.da.modesName': ['Auto', '1', '2', '3', '4', 'MAX'], + } + assert _wind_strength_label('32', rep) == '2' + assert _wind_strength_label('35', rep) == 'max' + + +def test_wind_strength_label_falls_back_to_raw_code_when_names_absent(): + assert _wind_strength_label('32', {}) == '32' + + +def test_fan_modes_include_every_supported_speed_not_just_auto(): + """Before the fix, only '0' matched _DEVICE_TO_FAN and fan_modes was + ['auto'] -- exactly the reported symptom.""" + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.fan_modes == ['auto', '1', '2', '3', '4', 'max'] + + +def test_fan_mode_reads_the_current_dynamic_code(): + """Fixture's /wind/strength/vs/0 modes is '32' -> modesName '2'.""" + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.fan_mode == '2' + + +def test_standard_scale_codes_still_use_device_to_fan(): + """A code _DEVICE_TO_FAN already covers keeps its existing friendly + label rather than falling through to the device's own (blunter) one -- + no regression for boards using the standard "0"-"4" scale.""" + resources = _load_device(FIXTURE) + resources['/wind/strength/vs/0']['x.com.samsung.da.modes'] = '0' + entity = _climate(resources) + assert entity.fan_mode == _DEVICE_TO_FAN['0'] == 'auto' + + +async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('max') + + assert coordinator.commands[-1][1] == ('fan', '35') + + +async def test_set_fan_mode_still_resolves_standard_scale_labels(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('auto') + + assert coordinator.commands[-1][1] == ('fan', '0') + + +async def test_set_fan_mode_does_not_misroute_when_static_map_and_live_codes_collide(): + """A board can use non-standard codes ('31'-'33') while modesName still + spells a standard-looking label ('Low'/'High') that _FAN_TO_DEVICE's + static reverse map also happens to have an entry for ('1'/'3') -- but + that entry is for a *different* code this unit never advertises at all. + Resolving the static hit without checking it against this unit's own + supportedModes would silently write a code the device doesn't have. + """ + resources = _load_device(FIXTURE) + resources['/wind/strength/vs/0'] = { + 'x.com.samsung.da.modes': '0', + 'x.com.samsung.da.supportedModes': ['0', '31', '32', '33'], + 'x.com.samsung.da.modesName': ['Auto', 'Low', 'High', 'Turbo'], + } + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + assert entity.fan_modes == ['auto', 'low', 'high', 'turbo'] + + await entity.async_set_fan_mode('high') + assert coordinator.commands[-1][1] == ('fan', '32') + + await entity.async_set_fan_mode('low') + assert coordinator.commands[-1][1] == ('fan', '31') + + await entity.async_set_fan_mode('turbo') + assert coordinator.commands[-1][1] == ('fan', '33') diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index e9905b9..c5076ae 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -7,8 +7,8 @@ testable directly. from homeassistant.components.climate import HVACMode from custom_components.localthings.climate import ( - _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, - _preset_to_ha, + _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, + PRESET_AI_COMFORT, _preset_to_ha, ) @@ -74,3 +74,45 @@ def test_preset_to_ha_lowercases_other_codes(): assert _preset_to_ha('Sleep') == 'sleep' assert _preset_to_ha('NanoSleep') == 'nanosleep' assert _preset_to_ha('MotionIndirect') == 'motionindirect' + + +def test_fac_bora_wind_strength_codes_fit_the_standard_scale(): + """TP2X_FAC_BORA_21K's (issues #150/#153) /wind/strength/vs/0 codes + (0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already + match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName + fallback needed for this particular board, unlike issue #155's + TP1X_DA-AC-RAC-01001_0000. + + Asserts the live climate entity's actual fan_modes/fan_mode output + (not just that the module constant _DEVICE_TO_FAN happens to have + these keys) -- a bare `code in _DEVICE_TO_FAN` check would still pass + even if fan_modes/fan_mode were completely broken, since it never + touches the entity at all. + """ + from custom_components.localthings.climate import LocalThingsClimate + from custom_components.localthings.registry import by_type + from custom_components.localthings.registry.discovery import discover + from custom_components.localthings.registry.entities import ClimateDesc + from tests.conftest import _load_device + + class _FakeCoordinator: + device_serial = 'TEST-FAC-BORA-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + + def resource(self, href): + return self.last_resources.get(href, {}) + + resources = _load_device('airconditioner_fac_bora') + info = resources['/information/vs/0'] + reg = by_type.for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) + entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound) + + assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo'] + assert entity.fan_mode == 'auto' diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 2050a70..d536165 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -184,6 +184,40 @@ class TestWmSetinfoFlags: }, ]) == 'LE' + def test_off_suffixed_placeholder_codes_filtered(self): + """issue #166: Samsung pre-populates /alarms/vs/0 with one row per + supported alarm *type*, each carrying a '_OFF' placeholder + code (no 'Deleted' state at all) when that alarm isn't firing -- + confirmed not just for 'ErrorCode_OFF' but also 'FilterAlarm_OFF' + on the same dump. These were previously shown as if they were + active alarms.""" + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'ErrorCode_OFF', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + { + 'x.com.samsung.da.code': 'FilterAlarm_OFF', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + ]) == 'none' + + def test_real_filter_alarm_not_off_suffixed_still_shown(self): + """issue #166's actual live filter alert: code 'FilterAlarm' (no + '_OFF' suffix), state 'Created' -- distinct from the 'FilterAlarm_OFF' + placeholder and must still surface.""" + assert common._active_alarm_codes([ + { + 'x.com.samsung.da.code': 'ErrorCode_OFF', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + { + 'x.com.samsung.da.code': 'FilterAlarm', + 'x.com.samsung.da.state': 'Created', + 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', + }, + ]) == 'FilterAlarm' + class TestKidsLockFallback: def test_generic_read_write(self): diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 59702e2..89591e8 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -209,3 +209,19 @@ def test_discover_rt_filter_gates_binding(): resources = {'/mode/vs/0': {'rt': ['x.com.samsung.da.mode'], 'x.com.samsung.da.mode': 'Bake'}} bound = discover(resources, {'/mode/vs/0': [oven_mode_cap]}) assert bound == [] + + +def test_discover_tier_log_fires_for_no_entity_coverage_cap(): + """A coverage-only Capability (entities=(), used to mark a href as + handled by e.g. a composite climate entity rather than its own HA + entity) produces zero BoundEntity rows -- `bound` alone can't tell a + caller its poll_tier. tier_log must still report it, or a consumer + (the coordinator's hot/warm href lists) silently treats it as 'cold' + regardless of what poll_tier the capability actually declares.""" + coverage_cap = Capability(href='/wind/strength/vs/0', poll_tier='warm') + resources = {'/wind/strength/vs/0': {'x.com.samsung.da.modes': '0'}} + seen = [] + bound = discover(resources, {coverage_cap.href: [coverage_cap]}, + tier_log=lambda href, tier: seen.append((href, tier))) + assert bound == [] + assert seen == [('/wind/strength/vs/0', 'warm')] diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 917e12c..b7ed571 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -191,6 +191,27 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us(): ) +def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu(): + """TP1X_REF_21K, EU region variant (issue #165) -- self-reports + oneUiVersion "7.0 Refrigerator" like the US variant, but additionally + carries /rm/control/vs/0 (a bare resource-monitoring poll-interval + config, added to the global ignore list) that the US dump doesn't + report at all. Door sensors (the reporter's actual ask) were already + covered by fridge.DOOR_GENERIC/DOORS_FALLBACK -- this href was the only + gap keeping the coverage repair open.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_tp1x_ref_21k_eu') + golden = json.loads( + (GOLDEN / 'refrigerator_tp1x_ref_21k_eu.json').read_text() + ) + state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_eu', 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_range_hood(): from tests.conftest import _load_device resources = _load_device('range_hood') @@ -457,6 +478,12 @@ def test_registry_reproduces_golden_state_keys_for_induction_cooktop(): resources = _load_device('induction_cooktop') golden = json.loads((GOLDEN / 'induction_cooktop.json').read_text()) state_keys = _new_state_keys('induction_cooktop', 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_range_no_info(): """NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no @@ -544,6 +571,23 @@ def test_registry_reproduces_golden_state_keys_for_range_ne8300d(): ) +def test_registry_reproduces_golden_state_keys_for_range_ne63a6511(): + """NE63A6511SS/AA (issue #138) -- reports no /information/vs/0 at all, + same shape as issue #74's NE63B8411SS; resolved via the same + 'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature in + for_device_by_resources. Binds cleanly against the existing range + registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('range_ne63a6511') + golden = json.loads((GOLDEN / 'range_ne63a6511.json').read_text()) + state_keys = _new_state_keys('range_ne63a6511', 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_ara_ww_tp1_22(): """ARA-WW-TP1-22-COMMON wall-mount RACs (model AR10/13/18BYEAAWKNME, issues #115/#116/#117/#120) report no oneUiVersion and no @@ -630,6 +674,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d(): ) +def test_registry_reproduces_golden_state_keys_for_microwave_me7500d_lamp_high(): + """Same TP1X_DA-KS-MICROWAVE-01051/ME7500D board as microwave_me7500d + above, but this live capture (issue #152) is the first to report a + non-Off Lamp token ('Lamp_High'). Locks in that the lamp switch reads + it as on rather than the previously-hardcoded 'On'-only comparison.""" + from tests.conftest import _load_device + resources = _load_device('microwave_me7500d_lamp_high') + golden = json.loads((GOLDEN / 'microwave_me7500d_lamp_high.json').read_text()) + state_keys = _new_state_keys('microwave_me7500d_lamp_high', 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_air_purifier_tp1x_da_ac_air(): """TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion '7.0 Air purifier' and resolves via for_device() onto the existing @@ -692,6 +752,103 @@ def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001(): + """TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN, issue #155) -- binds + cleanly against the existing airconditioner registry with zero unbound + hrefs (the registry/discovery side was never the gap here). Its + /wind/strength/vs/0 reports supportedModes "0"/"31"-"35" instead of the + "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from, which silently + dropped every fan speed but Auto -- see + test_airconditioner_tp1x_rac_01001_fan.py for the climate-level fix.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_rac_01001') + golden = json.loads( + (GOLDEN / 'airconditioner_tp1x_rac_01001.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_tp1x_rac_01001', 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_air_dresser(): + """DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports + no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no + /wm/editcourse/vs/0 at all, so the course select's option list comes + entirely from laundry.cycle_options' supportedOptions fallback. Binds + cleanly against the new air_dresser registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_dresser') + golden = json.loads((GOLDEN / 'air_dresser.json').read_text()) + state_keys = _new_state_keys('air_dresser', 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_air_dresser_tp2_20(): + """DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157) -- a + different board generation than issue #162's DA_DF_A51_20_COMMON, also + routed via the '_DF_' modelNum fallback into the same air_dresser + registry. Unlike #162's board, this one populates /wm/editcourse/vs/0's + editCourseList directly (no supportedOptions fallback needed) and + reports two AirDresser-specific resources #162 doesn't have: + /st/airdressercourse/vs/0 (course table id, ignored.py) and + /airdresseroption/sanitize/vs/0 (air_dresser.AIR_DRESSER_SANITIZE). + Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_dresser_tp2_20') + golden = json.loads((GOLDEN / 'air_dresser_tp2_20.json').read_text()) + state_keys = _new_state_keys('air_dresser_tp2_20', 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_air_purifier_vtww(): + """A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no + oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into + the existing air_purifier registry. Its fan lives on + /wind/strength/vs/0 (air_purifier.WIND_STRENGTH_FAN) rather than the + /mode/vs/0 FAN the other two board generations in this registry use. + Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier_vtww') + golden = json.loads((GOLDEN / 'air_purifier_vtww.json').read_text()) + state_keys = _new_state_keys('air_purifier_vtww', 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_fac_bora(): + """TP2X_FAC_BORA_21K Wind-Free 2-in-1 (floor-standing + wall-mounted + indoor units sharing one outdoor unit and one local IP, issues + #150/#153) -- reports no oneUiVersion; resolved via the '_FAC_' + modelNum fallback. Binds cleanly against the existing airconditioner + registry (including a real climate entity, the actual reported gap) + with zero unbound hrefs -- /subdevices/vs/0 and /runn/vs/0 are the only + hrefs this board reports that no other AC family does, both added to + _AC_IGNORED as undocumented/not-locally-actionable.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_fac_bora') + golden = json.loads((GOLDEN / 'airconditioner_fac_bora.json').read_text()) + state_keys = _new_state_keys('airconditioner_fac_bora', 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 dump = { diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 05f0b91..f01d096 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -120,9 +120,23 @@ def test_power_level_handles_missing_value(): def test_microwave_mode_options_nonempty(): desc = microwave.MICROWAVE_MODE.entities[0] - assert len(desc.options) > 0 - assert 'MicroWave' in desc.options - assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry' + assert callable(desc.options) + options = desc.options({}) + assert len(options) > 0 + assert 'MicroWave' in options + assert 'AirFryer' in options # distinct spelling from oven.py's 'AirFry' + + +def test_microwave_mode_options_reads_live_supported_modes(): + """issue #152's ME7500D reports only 4 of the 11 union-of-all-dumps + _MICROWAVE_MODES -- the live supportedModes list is used verbatim when + present, same live-first pattern as oven._oven_mode_options, instead of + offering users modes their own unit doesn't have.""" + desc = microwave.MICROWAVE_MODE.entities[0] + resources = {'/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'], + }} + assert desc.options(resources) == ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'] def test_microwave_mode_write_round_trips(): @@ -137,6 +151,19 @@ def test_microwave_mode_rejects_unknown(): assert desc.write_fn('SpaghettiMode', {}) is None +def test_microwave_mode_write_validates_against_live_supported_modes(): + """A device reporting its own supportedModes is validated against that + list, not the static union-of-all-dumps fallback -- 'AirFryer' is a + valid _MICROWAVE_MODES entry but must still be rejected for a unit + whose own supportedModes doesn't include it.""" + desc = microwave.MICROWAVE_MODE.entities[0] + rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']} + path, body = desc.write_fn('MicroWave', rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['MicroWave'] + assert desc.write_fn('AirFryer', rep) is None + + # --------------------------------------------------------------------------- # MICROWAVE_MODE — lamp/sound options-array writes # --------------------------------------------------------------------------- @@ -165,13 +192,28 @@ def test_lamp_gated_present_when_lamp_option_reported(): def test_lamp_write_is_single_token(): + """issue #152: the device has never been observed accepting 'On' -- + only 'High'/'Off' -- so the switch's "on" write uses 'High'.""" desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') rep = {'x.com.samsung.da.options': ['Lamp_Off']} path, body = desc.write_fn('On', rep) assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Lamp_On']} + assert body == {'x.com.samsung.da.options': ['Lamp_High']} def test_lamp_write_requires_existing_options(): desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') assert desc.write_fn('On', {}) is None + + +def test_lamp_reads_off_as_false(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + assert desc.value_fn(['Lamp_Off']) is False + + +def test_lamp_reads_any_non_off_level_as_true(): + """issue #152's ME7500D reports 'Lamp_High', not the binary 'Lamp_On' + #137's dump implied -- any non-Off/non-absent value must read as on, not + just a literal 'On'.""" + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') + assert desc.value_fn(['Lamp_High']) is True diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index 22074df..926fd76 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -120,12 +120,29 @@ def test_oven_setpoint_native_bounds_track_live_unit(): # --------------------------------------------------------------------------- def test_oven_mode_options_nonempty(): - assert len(oven.OVEN_MODE.entities[0].options) > 0 + desc = oven.OVEN_MODE.entities[0] + assert callable(desc.options) + assert len(desc.options({})) > 0 + + +def test_oven_mode_options_falls_back_when_no_live_supported_modes(): + desc = oven.OVEN_MODE.entities[0] + assert desc.options({}) == list(oven._OVEN_MODES) + + +def test_oven_mode_options_reads_live_supported_modes(): + """issue #138: the device's own supportedModes list is used verbatim + when present, instead of the static _OVEN_MODES guess.""" + desc = oven.OVEN_MODE.entities[0] + resources = {'/mode/vs/0': { + 'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean'], + }} + assert desc.options(resources) == ['Bake', 'AirFryer', 'SelfClean'] def test_oven_mode_write_round_trips(): desc = oven.OVEN_MODE.entities[0] - valid_mode = desc.options[1] # e.g. 'Bake' + valid_mode = desc.options({})[1] # e.g. 'Bake' path, body = desc.write_fn(valid_mode, {}) assert path == ['mode', 'vs', '0'] assert body['x.com.samsung.da.modes'] == [valid_mode] @@ -136,6 +153,19 @@ def test_oven_mode_rejects_unknown(): assert desc.write_fn('SpaghettiMode', {}) is None +def test_oven_mode_write_validates_against_live_supported_modes(): + """A device reporting its own supportedModes is validated against that + list, not the static fallback -- issue #138's AirFryer/SelfClean/etc. + are accepted, and a mode outside the device's own list is rejected even + if some other model's static guess would have allowed it.""" + desc = oven.OVEN_MODE.entities[0] + rep = {'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean']} + path, body = desc.write_fn('AirFryer', rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == ['AirFryer'] + assert desc.write_fn('FrozenPizzaPlus', rep) is None + + # --------------------------------------------------------------------------- # OVEN_MODE options-array writes (lamp, sound, fast_preheat, natural_steam). # Confirmed on real hardware (issue #54): a write only needs to carry the diff --git a/tests/test_range_ne63a6511_capabilities.py b/tests/test_range_ne63a6511_capabilities.py new file mode 100644 index 0000000..c52da95 --- /dev/null +++ b/tests/test_range_ne63a6511_capabilities.py @@ -0,0 +1,68 @@ +"""Tests for the NE63A6511SS/AA range (issue #138) -- same no-/information/vs/0, +no-burner-status shape as issue #74's NE63B8411SS, confirming the existing +range_no_info detection path still resolves this model with zero unbound +hrefs, plus oven.OVEN_MODE reading this dump's ConvectionRoast/KeepWarm/ +BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own +/mode/vs/0 supportedModes rather than needing them hardcoded.""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_resources +from custom_components.localthings.registry.capabilities import oven +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range_ne63a6511') + reg = for_device_by_resources(resources) + return reg, resources + + +def _state(): + reg, resources = _range() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_range_registry(): + reg, _ = _range() + assert reg is not None and reg.name == 'range' + + +def test_no_unbound_hrefs(): + reg, resources = _range() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', + 'machine_state', 'door_open', 'cloud_connected', 'child_lock', + 'cooktop_running_state', 'warming_center_state', + ): + assert key in state, key + + +def test_oven_mode_accepts_this_devices_supported_modes(): + """This dump's /mode/vs/0 supportedModes reports ConvectionRoast, + KeepWarm, BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean -- + none of which were in the static oven._OVEN_MODES fallback, so a + hardcoded write-validation list would've silently rejected them even + though the device itself advertises them. write_fn reads supportedModes + off the live rep (the same one it's called with in production -- see + coordinator.py's async_send_command), so this passes without needing + those modes added to any Python list.""" + _, resources = _range() + live_rep = resources['/mode/vs/0'] + desc = oven.OVEN_MODE.entities[0] + assert desc.options(resources) == live_rep['x.com.samsung.da.supportedModes'] + for mode in ( + 'ConvectionRoast', 'KeepWarm', 'BreadProof', 'AirFryer', + 'Dehydrate', 'SelfClean', 'SteamClean', + ): + path, body = desc.write_fn(mode, live_rep) + assert path == ['mode', 'vs', '0'] + assert body['x.com.samsung.da.modes'] == [mode]