diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index c2b50b1..66cad38 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -194,7 +194,17 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]: except OSError: pass - return _order_candidates(live) + # The sweep's ICMP-based verdict isn't reliable on every network path -- + # issue #192 captured a segregated-VLAN device where it called three + # ports live that a concurrent nmap scan showed as closed, while the + # port nmap found genuinely open|filtered (49154, one of our historically + # confirmed ports) never showed up as live at all. Rather than trust a + # wrong "not live" verdict on a port we already have strong prior + # evidence for, always give the historically-confirmed ports a real + # handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS) + # extra handshakes, only when the sweep disagrees with the prior. + rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live] + return _order_candidates(live + rescued) def _is_placeholder_serial(serial: str) -> bool: @@ -238,14 +248,13 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: candidates = _find_live_ports( host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S ) - if not candidates: - # Every port in the range actively refused: there is no DTLS/CoAP - # listener on this host, so a handshake can't succeed. Fail now with a - # clear message instead of retrying doomed ports. - raise CannotConnect( - f"no live DTLS port found on {host} " - f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}" - ) + # No early "every port refused" fast-fail here: _find_live_ports always + # rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never + # empty as long as that table is non-empty and within PROBE_PORT_RANGE -- + # both true today, which made this branch permanently unreachable. A + # genuinely dead host now fails via the handshake loop's own error below, + # which carries the actual per-port timeout/refusal reason instead of a + # generic "no live port found" message. _LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates) last_exc = None diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 9f860bd..9b42c58 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -75,17 +75,24 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = { # Air conditioners. Every one of these is a distinct board family with # the same resource surface: room (issues #37, #91), package, Korean # (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial - # (#52), and ARA-WW wall-mount (#115, #116, #117, #120). + # (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120). 'RAC': 'airconditioner', 'PRAC': 'airconditioner', 'KRAC': 'airconditioner', 'WAC': 'airconditioner', 'FAC': 'airconditioner', 'CAWW': 'airconditioner', + 'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000 'ARA': 'airconditioner', 'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate 'TVTL': 'air_purifier', # issue #56 (ARTIK051) 'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air) + 'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a + # next-gen BESPOKE Cube Air board; same + # lineage as VTWW above but the '-WW-' + # delimiter now falls one letter to the + # left ('A-VTWW-' -> 'AVT-WW-'), splitting + # into a token the existing entry can't see 'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR) 'WATERPURIFIER': 'water_purifier', # issue #90 'ADW': 'dishwasher', diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 683496e..50ef840 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -16,6 +16,11 @@ match_fn discriminators that keep them from colliding): token, added for it. Its fan is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on /mode/vs/0 -- see that capability's comment. +- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW + lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter + falls one letter to the left of 'VTWW's whole-token spelling. Same + resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities + needed. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write contract). diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 4d61fff..f6c08c7 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -7,6 +7,13 @@ this registry includes *common.UNIVERSAL but deliberately NOT common.POWER -- on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's own comment in capabilities/common.py for why it's excluded. +common.ENERGY_METER itself is also excluded from UNIVERSAL here, replaced by +the ENERGY_METER_GENERIC/ENERGY_METER_LEGACY pair -- the legacy ARTIK051 board +generation (issue #193) reports cumulativePower in a different unit than +every other AC family, so this registry needs two mutually-exclusive variants +of that one capability instead of the single shared one every other registry +uses unconditionally. + Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. """ from ..capabilities import airconditioner, common, dishwasher, ignored @@ -16,7 +23,9 @@ REGISTRY = DeviceRegistry( name='airconditioner', capabilities=_build([ *ignored.IGNORED, - *common.UNIVERSAL, + *[c for c in common.UNIVERSAL if c is not common.ENERGY_METER], + airconditioner.ENERGY_METER_GENERIC, + airconditioner.ENERGY_METER_LEGACY, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, airconditioner.AIR_PURIFY, diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 4c0abf5..b834820 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry( fridge.DEFROST_DELAY, fridge.DEFROST_DELAY_NATIVE_DUPLICATE, fridge.DEFROST_BLOCK_STATUS, + fridge.DEFINITE_TEMPERATURE_COOLER, fridge.DOORS_FALLBACK, fridge.TEMPERATURES_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f5c8983..1ed642a 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -14,10 +14,13 @@ None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`, different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ +from dataclasses import replace + from ..capability import Capability from ..entities import ( BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, ) +from . import common from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -285,17 +288,64 @@ def _option_token(rep, prefix): 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, - OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings, - so an ungated token entity would either duplicate an existing one or apply a - scale calibrated elsewhere. Every AC dump on record has one shape or the - other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no - /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. + Newer families carry several of the same option tokens (Sleep, OutdoorTemp, + Autoclean) *alongside* dedicated resources for those settings, so an + ungated token entity would either duplicate an existing one or apply a + scale calibrated elsewhere. (Volume used to be in this list too, back when + it had its own gated buzzer_volume Number for this board generation -- + issue #136 replaced that with the unified 'beep' switch, which applies + across every generation and isn't gated here at all.) Every AC dump on + record has one shape or the other: /airflow/vs/0 with no /wind/* at all, + or /wind/strength/vs/0 with no /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 HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources +# This legacy ARTIK051 board generation (issue #193, model AR12NXWXCWKNEU / +# ARTIK051_KRAC_18K) reports /energy/consumption/vs/0's cumulativePower in +# centiwatt-hours -- raw value 100x the plain Wh every other AC board family +# (and common.wh_to_kwh's assumed unit) reports. Confirmed against the +# reporter's own SmartThings-app reading: raw '117430000' vs the app's +# authoritative 1,174.30 kWh is exactly a /100000 factor (i.e. /100 on top of +# wh_to_kwh's own /1000), not wh_to_kwh's plain /1000 alone. No other field in +# ENERGY_METER's entities is present on this board's dump, so only +# 'energy_kwh' needs a replacement value_fn here; the rest pass through +# unchanged in case a future legacy dump ever reports them. +def _legacy_cumulative_power_kwh(v): + # float, not _int -- matches common.wh_to_kwh's own numeric parsing + # (float via _num) rather than this module's integer-only _int, so a + # decimal-formatted reading doesn't raise and silently go 'unknown'. + try: + n = float(v) + except (TypeError, ValueError): + return None + return round(n / 100000.0, 2) + + +ENERGY_METER_LEGACY = replace( + common.ENERGY_METER, + match_fn=lambda rep, resources: is_legacy_board(resources), + entities=tuple( + replace(e, value_fn=_legacy_cumulative_power_kwh) + if e.key == 'energy_kwh' else e + for e in common.ENERGY_METER.entities + ), +) + +# The non-legacy counterpart to ENERGY_METER_LEGACY above -- identical to +# common.ENERGY_METER, just excluding the legacy board generation so the two +# capabilities can share /energy/consumption/vs/0 in this registry without +# the 'multiple caps need a discriminator' build check tripping (common. +# ENERGY_METER itself has no match_fn, since every *other* registry includes +# it unconditionally and alone). +ENERGY_METER_GENERIC = replace( + common.ENERGY_METER, + match_fn=lambda rep, resources: not is_legacy_board(resources), +) + + def _has_option_token(prefix): return lambda rep, resources: ( is_legacy_board(resources) and _option_token(rep, prefix) is not None) @@ -445,14 +495,19 @@ 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): - # 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. + # + # Applies uniformly across board generations, including legacy + # ARTIK051 boards -- issue #136: this token was originally modeled as + # a 0-100 buzzer_volume Number for legacy boards on the assumption it + # was a real graduated volume level, but every unit confirmed on + # hardware (three units across two reporters) only ever carries + # Volume_100 or Volume_Mute, never an intermediate value, and the + # Number's write path (a plain integer string) could never produce + # the literal 'Mute' token needed to actually turn it off -- writing + # '0' is simply not a token this firmware recognizes. A switch is + # both the correct model and the actual fix. SwitchDesc(key='beep', rep_fn=_beep_on, - exists_fn=lambda rep, resources: ( - not is_legacy_board(resources) - and _option_token(rep, 'Volume') is not None), + exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None, write_fn=_beep_write, icon='mdi:volume-high', entity_category='config'), # Tropical night mode level (0-16) from the `Sleep_` option token. @@ -498,11 +553,6 @@ CLIMATE = Capability( exists_fn=_has_option_token('AirMonitoring'), write_fn=_option_switch_write('AirMonitoring'), icon='mdi:air-filter', entity_category='config'), - NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'), - exists_fn=_has_option_token('Volume'), - write_fn=_option_number_write('Volume'), - native_min=0, native_max=100, step=10, - icon='mdi:volume-high', entity_category='config'), # "Good Sleep" timer. 0 = off; the upper bound is a guess (the token # carries no range hint and only 0 has been observed on hardware), so a # write above 0 is unverified. diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 4ed9bab..45889b4 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -259,6 +259,12 @@ def sensor_item_value(items, sensor_type, index=0): POWER_GENERIC = Capability( href='/power/0', + # Neither href of this pair carried a poll_tier before (issue #56's + # follow-up), so power state only ever refreshed on the once-per-30s + # summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot' + # hrefs get -- the same "signal drives real-time state, but sat in the + # slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above. + poll_tier='warm', entities=( # Writable when firmware allows remote power; otherwise a read-only # binary_sensor with the same key keeps HA state without a dead switch. @@ -277,6 +283,7 @@ POWER_GENERIC = Capability( POWER_VS_FALLBACK = Capability( href='/power/vs/0', match_fn=lambda rep, resources: '/power/0' not in resources, + poll_tier='warm', entities=( SwitchDesc(key='power_switch', field='x.com.samsung.da.power', value_fn=lambda v: v == 'On', @@ -604,15 +611,22 @@ SELF_CHECK = Capability( # level: 2 of 6 families confirmed, blanket-added everywhere else). # # POWER is kept separate -- airconditioner is the one family that opts out -# of it. Canonical reason (see by_type/airconditioner.py and its test for -# pointers back here, not restatements): AC's climate entity already owns -# /power/0 and /power/vs/0 via bare, no-entity Capability objects -# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ +# of it entirely. Canonical reason (see by_type/airconditioner.py and its +# test for pointers back here, not restatements): AC's climate entity +# already owns /power/0 and /power/vs/0 via bare, no-entity Capability +# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ # POWER_VS_FALLBACK cap on the same href would make _build() raise (a href # with >1 cap must have every cap discriminated by rt_filter/match_fn, and # the bare COVERAGE cap has neither). Kids-lock/remote-control don't have # this conflict -- no AC dump has ever reported those hrefs -- so they stay # in UNIVERSAL. +# +# Airconditioner also partially opts out of UNIVERSAL itself, not just +# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by +# board generation, so by_type/airconditioner.py excludes just that one +# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`) +# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in +# its place -- every other registry still unpacks UNIVERSAL wholesale. # --------------------------------------------------------------------------- UNIVERSAL = ( diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 14e7436..e7e08a1 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -108,6 +108,37 @@ TEMP_SETPOINT = Capability( ), ) +# --------------------------------------------------------------------------- +# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only") +# fridges report no /temperature/current|desired/* pair at all (this href +# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/' +# or '/temperature/desired/' prefixes), only this one vendor resource that +# bundles the live desired value together with the *specific* values the +# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue +# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid +# setpoints on this model -- so a NumberDesc with a min/max/step would let a +# user pick an unsupported value; modeled as a select reading its own live +# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above. +# --------------------------------------------------------------------------- + +def _definite_cooler_write(p, rep, href=None): + return (['temperature', 'definite', 'cooler', 'vs', '0'], + {'x.com.samsung.da.definite.desired': p}) + + +DEFINITE_TEMPERATURE_COOLER = Capability( + href='/temperature/definite/cooler/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='cooler_temperature_setpoint', + field='x.com.samsung.da.definite.desired', + icon='mdi:thermometer', + entity_category='config', + options_field='x.com.samsung.da.definite.supportedList', + write_fn=_definite_cooler_write), + ), +) + # --------------------------------------------------------------------------- # Icemaker nighttime quiet mode # --------------------------------------------------------------------------- diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 0a28cc9..252416f 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -24,6 +24,7 @@ but is effectively read-only in practice. """ from datetime import datetime, timezone, timedelta +from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, @@ -130,6 +131,28 @@ def _option_value(options, prefix): return None +def _has_option(prefix): + """exists_fn for an options-array switch: bind only when the device's own + options[] actually carries a `_` token. + + fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as + an unverified guess (see module docstring) -- issue #183's dump (model + NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its + options[] at all, so both switches were phantom controls: always read as + off, and toggling them wrote a token the firmware never recognized in + the first place, hence "does not appear to do anything." + + `is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's + identical per-token exists_fn on its own options[]-array href: a stub + /device/0 seed rep (not yet sub-polled) has no options[] at all, and + without this an entity whose token is genuinely present would never get + a first chance to bind, since exists_fn runs before that first real + fetch lands. + """ + return lambda rep, resources: is_stub_rep(rep) or _option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + def _option_write(prefix, new_value): """A one-token x.com.samsung.da.options write, mirroring laundry.option_write. NOT independently confirmed on an oven -- issue @@ -199,44 +222,19 @@ def _oven_mode_write(p, rep, href=None): return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} -def _lamp_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('UpperLamp', p), - } - - -def _sound_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('Sound', p), - } - - -def _fastpreheat_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('fastpreheat', p), - } - - -def _naturalsteam_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('NaturalSteam', p), - } +def _option_switch_write(prefix): + """Factory for a single-token on/off options-array write -- lamp, sound, + fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all + a byte-for-byte copy of this same shape, one per prefix.""" + def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': _option_write(prefix, p), + } + return write # --------------------------------------------------------------------------- @@ -377,19 +375,38 @@ OVEN_MODE = Capability( SwitchDesc(key='lamp', field='x.com.samsung.da.options', icon='mdi:track-light', value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On', - write_fn=_lamp_write), + write_fn=_option_switch_write('UpperLamp')), SwitchDesc(key='sound', field='x.com.samsung.da.options', icon='mdi:volume-high', entity_category='config', value_fn=lambda opts: _option_value(opts, 'Sound') == 'On', - write_fn=_sound_write), + write_fn=_option_switch_write('Sound')), SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options', icon='mdi:fire', + exists_fn=_has_option('fastpreheat'), value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On', - write_fn=_fastpreheat_write), + write_fn=_option_switch_write('fastpreheat')), SwitchDesc(key='natural_steam', field='x.com.samsung.da.options', icon='mdi:kettle-steam', + exists_fn=_has_option('NaturalSteam'), value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On', - write_fn=_naturalsteam_write), + write_fn=_option_switch_write('NaturalSteam')), + # 120-hour energy-saving standby (issue #183): confirmed present in + # this unit's options[] (EnergySaving_On) and directly requested -- + # unlike fast_preheat/natural_steam above, this token is real on this + # hardware, just previously unbound entirely. + SwitchDesc(key='energy_saving', field='x.com.samsung.da.options', + icon='mdi:leaf', entity_category='config', + exists_fn=_has_option('EnergySaving'), + value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On', + write_fn=_option_switch_write('EnergySaving')), + # Cooktop-on alert (issue #183): also confirmed present + # (BurnerOnAlert_Off) though the reporter noted it mainly matters for + # the SmartThings app's own alerting, not local automation. + SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options', + icon='mdi:alert-circle-outline', entity_category='config', + exists_fn=_has_option('BurnerOnAlert'), + value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On', + write_fn=_option_switch_write('BurnerOnAlert')), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 7822ab1..45382cb 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -158,9 +158,6 @@ } }, "number": { - "buzzer_volume": { - "name": "Beep volume" - }, "cook_time": { "name": "Cook time" }, @@ -214,6 +211,9 @@ "100": "High" } }, + "cooler_temperature_setpoint": { + "name": "Cooler temperature" + }, "day_brightness": { "name": "Cabinet brightness", "state": { @@ -1018,6 +1018,12 @@ "natural_steam": { "name": "Natural steam" }, + "energy_saving": { + "name": "Energy saving" + }, + "cooktop_on_alert": { + "name": "Cooktop-on alert" + }, "power_switch": { "name": "Power" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 833e3cc..777c496 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -158,9 +158,6 @@ } }, "number": { - "buzzer_volume": { - "name": "Zoemervolume" - }, "cook_time": { "name": "Bereidingstijd" }, @@ -214,6 +211,9 @@ "100": "Hoog" } }, + "cooler_temperature_setpoint": { + "name": "Koeler temperatuur" + }, "day_brightness": { "name": "Helderheid kast", "state": { @@ -1018,6 +1018,12 @@ "natural_steam": { "name": "Natuurlijke stoom" }, + "energy_saving": { + "name": "Energiebesparing" + }, + "cooktop_on_alert": { + "name": "Waarschuwing kookplaat aan" + }, "power_switch": { "name": "Voeding" }, diff --git a/tests/fixtures/air_purifier_avt_ww_device.json b/tests/fixtures/air_purifier_avt_ww_device.json new file mode 100644 index 0000000..588713e --- /dev/null +++ b/tests/fixtures/air_purifier_avt_ww_device.json @@ -0,0 +1,381 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.periodicSensingInterval": "600", + "x.com.samsung.da.startSensingOnce": "On", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785320820", + "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": "03002300" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": {} + }, + { + "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" + ] + } + }, + { + "href": "/filter/hepafilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "30", + "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": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.startTime": "15:00:00", + "x.com.samsung.da.endTime": "23:30:00", + "x.com.samsung.da.visible": "true", + "x.com.samsung.da.useTimeSetting": "true", + "x.com.samsung.da.functionState": "true" + } + }, + { + "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": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T10:34:52", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "48656", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeDate": "1785353679", + "x.com.samsung.da.cumulativeDateUTC": "1785321279", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "CountOfSlave_0", + "SlavePower01_NotSupported", + "Pollution_Off", + "SmartSave_Off", + "OptionCode_23418", + "SmartSleep_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "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": [ + "10", + "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": [ + "9", + "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": [ + "6", + "1" + ] + } + ], + "x.com.samsung.da.total": "5" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000", + "x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500", + "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": "AC0", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02530A260327", + "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": "22120702,22022301", + "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.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2824500000", + "x.com.samsung.da.airconOptionList": [ + "WELCOMECARE", + "AI_PURIFY" + ], + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/humidity/0", + "rep": {} + }, + { + "href": "/humidity/vs/0", + "rep": {} + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "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": "AVT-WW-TP1-23-AXX500", + "versions": [ + "12260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "22120702", + "22022301" + ], + "visVersion": "221207" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "22120702", + "22022301" + ], + "visVersion": "221207" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "ASUS_2.4G" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/airconditioner_artik051_krac_energy_device.json b/tests/fixtures/airconditioner_artik051_krac_energy_device.json new file mode 100644 index 0000000..174e8e8 --- /dev/null +++ b/tests/fixtures/airconditioner_artik051_krac_energy_device.json @@ -0,0 +1,231 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "0", + "x.com.samsung.da.direction": "All" + } + }, + { + "href": "/airflow/0", + "rep": { + "speed": 0, + "direction": "All" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36", + "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": "2026-07-29T12:29:36", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "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": "23", + "x.com.samsung.da.current": "23", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 23.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 23.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "117430000", + "x.com.samsung.da.cumulativeDate": "1785337200" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Comode_Off", + "Sleep_0", + "OutdoorTemp_85", + "CoolCapa_35", + "WarmCapa_35", + "Spi_Off", + "Autoclean_Off", + "Light_Off", + "Volume_100", + "AirMonitoring_Off", + "AutocleanProgress_0", + "StopAutoClean_Idle", + "FilterTime_1425", + "FilterAlarmTime_500", + "OptionCode_56378", + "ExtendOptionCode_7", + "RacInfo_None", + "UpdateAllow_NotAllowed" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "modes": [ + "Cool" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000", + "x.com.samsung.da.description": "ARTIK051_KRAC_18K", + "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": "02016A200825", + "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": "17110700,17110600", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+03:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000" + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/airconditioner_cac_device.json b/tests/fixtures/airconditioner_cac_device.json new file mode 100644 index 0000000..1cfe5ff --- /dev/null +++ b/tests/fixtures/airconditioner_cac_device.json @@ -0,0 +1,919 @@ +{ + "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.periodicSensingInterval": "1800", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785327477", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + "x.com.samsung.da.supportedAutoExeState": [ + "Airpurify", + "Alarm", + "Sensing" + ], + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.autoExeSetting": "On", + "x.com.samsung.da.supportedAutoExeSetting": [ + "On", + "Off" + ] + } + }, + { + "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.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "DA_SAC_M_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + } + ], + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000100B4012C0000B20001000000", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4146100000", + "x.com.samsung.da.airconOptionList": [ + "AI_KOREA_COOLONLY_4.0", + "HOMECARE_WIZARD_V2", + "CLEAN_OPTION_SUPPORT", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "2026-07-29T07:26:56Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "1019", + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/edgelighting/vs/0", + "rep": { + "status": "On", + "convenientMode": "On", + "operatingOption": "On", + "welcomeLightStatus": "Off", + "colorOption": "3000K", + "mode": "Smart", + "modeSupportedList": [ + "Smart", + "High", + "Low" + ], + "colorSupportedList": [ + "3000K", + "4000K", + "6500K" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1785327589", + "x.com.samsung.da.instantaneousPower": "15", + "x.com.samsung.da.cumulativeSavedPower": "1611", + "x.com.samsung.da.cumulativePower": "84044", + "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/airdustPM1filter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "2400", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "11" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "180", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "26" + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "59" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00", + "x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_0000", + "x.com.samsung.da.serialNum": "**REDACTED**", + "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": "02706A260401", + "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.newVersionAvailable": "0", + "x.com.samsung.da.number": "02555A25052600,02556A24040200" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF" + } + ], + "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": "AS3", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/stateful/vs/0", + "rep": { + "status": "On", + "mode": "Smart", + "supportedModes": [ + "Smart", + "Low", + "High" + ] + } + }, + { + "href": "/mds/absenceclean/vs/0", + "rep": { + "mode": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": { + "status": "Off" + } + }, + { + "href": "/mds/absencepowersaving/vs/0", + "rep": { + "status": "On", + "modes": "Cooling", + "switchPowerSaveMode": "Normal", + "supportedModes": [ + "Standby", + "Cooling" + ], + "supportedSwitchPowerSaveMode": [ + "Eco", + "Normal", + "Comfort" + ] + } + }, + { + "href": "/mds/absencestate/vs/0", + "rep": { + "status": "On", + "absenceTime": "0", + "maxDetectCount": [ + "0", + "0", + "0", + "0", + "0", + "0", + "3", + "3", + "3", + "2", + "3", + "0", + "0", + "3", + "0", + "3", + "3", + "3", + "0", + "3", + "1", + "2", + "3", + "3", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "1", + "0", + "3", + "2", + "3", + "1", + "2", + "1", + "0", + "0", + "0", + "0" + ], + "supportedTimes": [ + "0", + "30", + "60", + "120" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Nano", + "LongWind", + "Speed", + "Sleep", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Auto", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.options": [ + "OptionCode_512", + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None", + "welcomecare_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "ExtendOptionCode_0", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "OutdoorTemp_55", + "KeyInputPermit_On", + "ModePermit_NoLimit" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.current": "28.5", + "x.com.samsung.da.desired": "28.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/motiondetectwind/stateful/vs/0", + "rep": { + "status": "On", + "modes": "Indirect", + "supportedModes": [ + "Indirect", + "Direct" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "46" + } + }, + { + "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": "/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": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sac/installationinfo/vs/0", + "rep": { + "installationOptions": [ + { + "id": "0", + "option": "184449945E0484800017" + }, + { + "id": "1", + "option": "28010000010000000100" + }, + { + "id": "2", + "option": "50000000000000000001" + }, + { + "id": "3", + "option": "30000000000000000000" + } + ], + "installationDeviceNum": [ + { + "id": "0", + "number": "0" + }, + { + "id": "1", + "number": "5" + }, + { + "id": "2", + "number": "0" + } + ], + "outdoorInfo": [ + { + "id": "0", + "info": "02571A250514", + "serial": "**REDACTED**" + }, + { + "id": "1", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "2", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "3", + "info": "000000000000", + "serial": "**REDACTED**" + } + ], + "mcuInfo": [ + { + "id": "0", + "info": "000000000000" + }, + { + "id": "1", + "info": "000000000000" + }, + { + "id": "2", + "info": "000000000000" + }, + { + "id": "3", + "info": "000000000000" + }, + { + "id": "4", + "info": "000000000000" + }, + { + "id": "5", + "info": "000000000000" + }, + { + "id": "6", + "info": "000000000000" + }, + { + "id": "7", + "info": "000000000000" + }, + { + "id": "8", + "info": "000000000000" + }, + { + "id": "9", + "info": "000000000000" + }, + { + "id": "10", + "info": "000000000000" + }, + { + "id": "11", + "info": "000000000000" + }, + { + "id": "12", + "info": "000000000000" + }, + { + "id": "13", + "info": "000000000000" + }, + { + "id": "14", + "info": "000000000000" + } + ] + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.cleanLevel": "1", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "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": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "7", + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "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": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + } + ] + } + }, + { + "href": "/settings/sound/mode/vs/0", + "rep": { + "mode": "voice", + "supportedModes": [ + "mute", + "tone", + "voice" + ] + } + }, + { + "href": "/settings/sound/optimization/vs/0", + "rep": { + "status": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/settings/sound/output/vs/0", + "rep": { + "deviceType": "speaker" + } + }, + { + "href": "/settings/sound/volume/vs/0", + "rep": { + "level": "2", + "minLevel": "0", + "maxLevel": "3", + "resolution": "1" + } + }, + { + "href": "/smartsensingcooling/vs/0", + "rep": { + "status": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "0.5" + } + }, + { + "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.current": "28.5", + "x.com.samsung.da.desired": "28.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/uvled/vs/0", + "rep": { + "x.com.samsung.da.modes": "On", + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.pushAlarmStatus": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High" + ] + } + }, + { + "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 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ASA-WW-TP1-24-INFSAC", + "versions": [ + "14260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "04001025554110255641", + "versions": [ + "25052600", + "24040200" + ], + "visVersion": "250526" + }, + { + "type": "Micom", + "modelId": "FGCAN2", + "versions": [ + "", + "" + ], + "visVersion": "999999" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "boksil" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/air_purifier_avt_ww.json b/tests/fixtures/golden/air_purifier_avt_ww.json new file mode 100644 index 0000000..8f13b0e --- /dev/null +++ b/tests/fixtures/golden/air_purifier_avt_ww.json @@ -0,0 +1,19 @@ +{ + "state_keys": [ + "alarm_code", + "clean_level", + "device_active", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fine_dust", + "firmware_update", + "hepa_filter_status", + "hepa_filter_usage", + "mute_once", + "odor", + "power_switch", + "super_fine_dust", + "wind_strength_fan" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json index c7fabfb..e525909 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -3,7 +3,7 @@ "air_monitoring", "alarm_code", "auto_clean_legacy", - "buzzer_volume", + "beep", "climate", "current_temperature_c", "diagnosis_status", @@ -15,4 +15,4 @@ "outdoor_temperature", "spi" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json new file mode 100644 index 0000000..e525909 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_monitoring", + "alarm_code", + "auto_clean_legacy", + "beep", + "climate", + "current_temperature_c", + "diagnosis_status", + "display_light", + "energy_kwh", + "filter_time", + "good_sleep", + "humidity", + "outdoor_temperature", + "spi" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json new file mode 100644 index 0000000..e26afee --- /dev/null +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -0,0 +1,30 @@ +{ + "state_keys": [ + "absence_power_saving_active", + "absence_power_saving_mode", + "air_filter_status", + "air_filter_usage", + "air_filter_usage_hours", + "air_purify", + "alarm_code", + "auto_clean", + "clean_level", + "climate", + "current_temperature_c", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fine_dust", + "firmware_update", + "humidity", + "motion_detect_wind_active", + "motion_detect_wind_mode", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "super_fine_dust", + "tropical_night_mode" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/oven.json b/tests/fixtures/golden/oven.json index e2f168d..59a059a 100644 --- a/tests/fixtures/golden/oven.json +++ b/tests/fixtures/golden/oven.json @@ -7,12 +7,10 @@ "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", @@ -22,4 +20,4 @@ "remote_control", "sound" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/range.json b/tests/fixtures/golden/range.json index f386342..f7fcf75 100644 --- a/tests/fixtures/golden/range.json +++ b/tests/fixtures/golden/range.json @@ -27,12 +27,11 @@ "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -42,4 +41,4 @@ "remote_control", "sound" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/range_ne63a6511.json b/tests/fixtures/golden/range_ne63a6511.json index a7ef5a5..e43255a 100644 --- a/tests/fixtures/golden/range_ne63a6511.json +++ b/tests/fixtures/golden/range_ne63a6511.json @@ -4,16 +4,16 @@ "child_lock", "cloud_connected", "cook_time", + "cooktop_on_alert", "cooktop_running_state", "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -24,4 +24,4 @@ "sound", "warming_center_state" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/range_ne6516a.json b/tests/fixtures/golden/range_ne6516a.json new file mode 100644 index 0000000..65c82ac --- /dev/null +++ b/tests/fixtures/golden/range_ne6516a.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "cooktop_on_alert", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_running_state", + "current_temp_c", + "cycle_active", + "door_open", + "energy_saving", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound", + "warming_center_state" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/range_ne8300d.json b/tests/fixtures/golden/range_ne8300d.json index 49f4d08..b77d20e 100644 --- a/tests/fixtures/golden/range_ne8300d.json +++ b/tests/fixtures/golden/range_ne8300d.json @@ -8,12 +8,11 @@ "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", diff --git a/tests/fixtures/golden/range_no_info.json b/tests/fixtures/golden/range_no_info.json index a7ef5a5..e43255a 100644 --- a/tests/fixtures/golden/range_no_info.json +++ b/tests/fixtures/golden/range_no_info.json @@ -4,16 +4,16 @@ "child_lock", "cloud_connected", "cook_time", + "cooktop_on_alert", "cooktop_running_state", "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -24,4 +24,4 @@ "sound", "warming_center_state" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/refrigerator_definite_cooler.json b/tests/fixtures/golden/refrigerator_definite_cooler.json new file mode 100644 index 0000000..24d5970 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_definite_cooler.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "ai_energy_level", + "alarm_code", + "auto_door_opener", + "brightness_level", + "cabinet_light_dim", + "cabinet_light_switch", + "cooler_temperature_setpoint", + "day_brightness", + "door_alert", + "door_cooler_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "fridge_sound", + "night_end", + "night_start", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} \ No newline at end of file diff --git a/tests/fixtures/range_ne6516a_device.json b/tests/fixtures/range_ne6516a_device.json new file mode 100644 index 0000000..474ad22 --- /dev/null +++ b/tests/fixtures/range_ne6516a_device.json @@ -0,0 +1,215 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "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": "2026-07-28T23:15:35" + } + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Run" + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "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": "/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" + ] + } + }, + { + "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", + "meatprobe_disconnected", + "UpperLamp_Off", + "Sound_On", + "AdjustingTemp_23", + "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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Broil\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61441\",\"tempMaxC\":\"61442\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61441\",\"tempMaxF\":\"61442\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"ConvectionBake\",\"version\":\"0100\",\"default\":\"Default\",\"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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"KeepWarm\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"BreadProof\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"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\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Dehydrate\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"105\",\"tempDefaultC\":\"65\",\"tempListLengthC\":\"0\",\"tempMinF\":\"100\",\"tempMaxF\":\"225\",\"tempDefaultF\":\"150\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SelfClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SteamClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"}]", + "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": "/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": "Lock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "/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" + ], + "rt": [ + "x.com.samsung.da.cooktopMonitoring" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_definite_cooler_device.json b/tests/fixtures/refrigerator_definite_cooler_device.json new file mode 100644 index 0000000..384d933 --- /dev/null +++ b/tests/fixtures/refrigerator_definite_cooler_device.json @@ -0,0 +1,401 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "href": "/alarms/vs/0", + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/total/vs/0", + "rep": { + "x.com.samsung.da.lightLevel": "100", + "x.com.samsung.da.lightResolution": "3", + "x.com.samsung.da.lightControl.off.include": "Off", + "x.com.samsung.da.lightControl": "Off", + "x.com.samsung.da.lightControl.hide": "true", + "light.dimming.status": "On", + "href": "/cabinet/light/total/vs/0", + "rt": [ + "x.com.samsung.da.cabinetlight" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/settings/sound/alert/door/vs/0", + "rep": { + "alert.door": "3", + "supportedAlert.door": [ + "1", + "2", + "3", + "4" + ], + "href": "/settings/sound/alert/door/vs/0", + "rt": [ + "x.com.samsung.alert.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/enhanced/vs/0", + "rep": { + "light.control.status": "Off", + "level.brightness.daytime": "100", + "level.brightness.nighttime": "100", + "night.starttime": "N/A", + "night.duration.minute": "N/A", + "timezone.offset": "-03:00", + "href": "/cabinet/light/enhanced/vs/0", + "rt": [ + "x.com.samsung.da.light.enhanced" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/temperature/definite/cooler/vs/0", + "rep": { + "x.com.samsung.da.definite.desired": "2", + "x.com.samsung.da.definite.supportedList": [ + "1", + "2", + "3", + "4", + "7" + ], + "x.com.samsung.da.unit": "Celsius", + "href": "/temperature/definite/cooler/vs/0" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close", + "href": "/door/cooler/0", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door" + }, + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.realSaving": "Off", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "9", + "x.com.samsung.da.instantaneousPower": "51", + "x.com.samsung.da.cumulativePower": "593883", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "href": "/energy/consumption/vs/0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-03:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off", + "href": "/refrigeration/vs/0", + "rt": [ + "x.com.samsung.da.fridge" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1" + ], + "href": "/energy/ailevel/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00174941|0006012A001811304100000020010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RT1", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "260617", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "24010202, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.device.sound": "On", + "x.com.samsung.da.childlock": "Locked", + "href": "/status/lock/vs/0", + "rt": [ + "x.com.samsung.da.lockstatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "href": "/mode/vs/0", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true", + "href": "/realtimenotiforclient/vs/0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0, + "href": "/runningmode/vs/0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": [ + "Start" + ], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "ErrorCode_None" + ], + "href": "/selfcheck/vs/0" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-24-T4-COM", + "versions": [ + "20260617" + ], + "visVersion": "260617" + }, + { + "type": "Micom", + "modelId": "035000174941FFFFFFFF", + "versions": [ + "24010202", + "FFFFFFFF" + ], + "visVersion": "240102" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Sao_Paulo", + "offset": "-03:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "Pumba_IoT" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 6808b7d..8d40bd3 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -105,6 +105,47 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None: assert result == [live_port] +def test_find_live_ports_rescues_preferred_ports_the_sweep_missed( + socket_enabled, monkeypatch, +) -> None: + """Issue #192: a segregated VLAN made the ICMP-based sweep call three + closed ports live while missing the one port (a historically confirmed + DTLS port) that nmap showed as genuinely open|filtered. The sweep's + verdict on a preferred port shouldn't be trusted blindly -- it must + always come back as a candidate even if the sweep marked it dead, so the + config flow gets a real handshake attempt against it. + + Uses an OS-assigned port monkeypatched into PREFERRED_PROBE_PORTS rather + than the real 49154/49155, so this doesn't depend on those specific + system ports being free on whatever machine runs the suite. + """ + import socket + + from custom_components.localthings import config_flow + from custom_components.localthings.config_flow import _find_live_ports + + # Bind an OS-assigned port and immediately close it, same technique + # test_find_live_ports_detects_silent_port uses for its "closed" ports -- + # once closed, loopback refuses datagrams to it, standing in for the + # sweep wrongly ruling out a port we have strong prior evidence for. + reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + reserved.bind(('127.0.0.1', 0)) + preferred_port = reserved.getsockname()[1] + reserved.close() + monkeypatch.setattr(config_flow, 'PREFERRED_PROBE_PORTS', [preferred_port]) + + live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + live_sock.bind(('127.0.0.1', 0)) + live_port = live_sock.getsockname()[1] + + try: + result = _find_live_ports('127.0.0.1', [preferred_port, live_port], 0.8) + finally: + live_sock.close() + + assert set(result) == {preferred_port, live_port} + + async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None: """A device that only answers on 49153 — outside the historical 49154/49155 pair — is found by the liveness sweep and its port is stored diff --git a/tests/test_air_purifier_avt_ww.py b/tests/test_air_purifier_avt_ww.py new file mode 100644 index 0000000..83e692b --- /dev/null +++ b/tests/test_air_purifier_avt_ww.py @@ -0,0 +1,62 @@ +"""Tests for the AVT-WW-TP1-23-AXX500 air-purifier profile (issue #190). + +Next-gen BESPOKE Cube Air board -- same resource surface as the +A-VTWW-TP2-21-COMMON family (issue #151), but the '-WW-' delimiter now falls +one letter to the left ('A-VTWW-' -> 'AVT-WW-'), splitting into an 'AVT'/'WW' +token pair the existing 'VTWW' whole-token entry can't see. The reporter's +diagnostics showed device_type 'unknown' with empty oneUiVersion and every +resource unbound -- once routed to the existing air_purifier registry, every +resource here binds with zero gaps. +""" +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 _resources(): + return _load_device('air_purifier_avt_ww') + + +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 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_wind_strength_fan_preset_names(): + """This dump's /wind/strength/vs/0 reports the same numeric-code + + modesName shape as the original VTWW fixture (SMART/MAX/WINDFREE/Sleep), + confirming the shared WIND_STRENGTH_FAN capability applies unchanged.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['wind_strength_fan'] == '87' + wind = resources['/wind/strength/vs/0'] + assert wind['x.com.samsung.da.modesName'] == ['SMART', 'MAX', 'WINDFREE', 'Sleep'] + + +def test_air_quality_and_filter_sensors_present(): + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['dust'] == 10 + assert state['fine_dust'] == 9 + assert state['odor'] == 1 + assert state['hepa_filter_usage'] == 0 + assert state['hepa_filter_status'] == 'normal' diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index d6b4b98..5e314fd 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -102,7 +102,7 @@ def test_token_entities_present_with_calibrated_values(): assert state['filter_time'] == 171.5 # token - 55 == 19 C, against a 19.4 C forecast at the time of the dump. assert state['outdoor_temperature'] == 19.0 - assert state['buzzer_volume'] == 100.0 + assert state['beep'] is True assert state['good_sleep'] == 0.0 assert state['spi'] is False assert state['auto_clean_legacy'] is False @@ -115,7 +115,7 @@ def test_token_entities_stay_off_newer_boards(): token entities would duplicate them (auto clean) or apply a scale calibrated on another board generation (outdoor temperature).""" state = _state('airconditioner_tp1x_rac') - for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume', + for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'good_sleep', 'outdoor_temperature', 'filter_time'): assert key not in state, key diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py new file mode 100644 index 0000000..586c365 --- /dev/null +++ b/tests/test_airconditioner_cac.py @@ -0,0 +1,70 @@ +"""Tests for the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191). + +0.16.0's device-type simplification dropped oneUiVersion detection on the +assumption every device it typed was already reachable via a modelNum board +token -- this board was the one exception (its oneUiVersion self-reports +"7.0 Air conditioner", but 'CAC' had never been added to the board-token +table), so it silently fell back to common caps and lost its climate entity. + +This dump is NOT fully covered yet -- ten hrefs remain unbound (edge +lighting, PM1 filter, a second stateful light resource, absence-clean, four +`/settings/sound/*` resources, smart-sensing-cooling, UV LED), all genuinely +new to this board generation. That's a real device-support gap, left +documented here rather than guessed at, per the 'don't guess' rule -- fixing +the routing regression was the scope of #191. +""" +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 + +_STILL_UNBOUND = frozenset({ + '/edgelighting/vs/0', + '/filter/airdustPM1filter/vs/0', + '/light/stateful/vs/0', + '/mds/absenceclean/vs/0', + '/settings/sound/mode/vs/0', + '/settings/sound/optimization/vs/0', + '/settings/sound/output/vs/0', + '/settings/sound/volume/vs/0', + '/smartsensingcooling/vs/0', + '/uvled/vs/0', +}) + + +def _resources(): + return _load_device('airconditioner_cac') + + +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 test_resolves_to_airconditioner_registry(): + assert _reg(_resources()).name == 'airconditioner' + + +def test_documented_coverage_gap_is_exactly_this_set(): + """Locks in the current, known-incomplete coverage so a future fix to + any of these hrefs shows up as a golden-regression diff (extra keys) to + update here, rather than silently shrinking this list unnoticed.""" + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert set(unbound) == _STILL_UNBOUND + + +def test_non_legacy_board_uses_the_generic_energy_scale(): + """This board reports /wind/strength/vs/0 (not /airflow/vs/0), so + is_legacy_board() is False and it must use the plain wh_to_kwh scale, + not the /100000 correction added for the unrelated ARTIK051_KRAC-class + board in issue #193.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['energy_kwh'] == round(84044 / 1000.0, 2) diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 851c629..e97bb25 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -814,13 +814,61 @@ def test_sensor_item_value_picks_first_value(): def test_beep_and_tropical_night_stay_off_legacy_krac_board(): """ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_ - option token, but they're already modeled as buzzer_volume/good_sleep - (see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also - bind there, or the same options[] slot would surface as two entities.""" + option token. Volume_ is modeled as the shared 'beep' switch (issue #136's + buzzer_volume Number never correctly modeled this board -- see + test_legacy_krac_board_beep_is_a_switch_not_a_volume_number below); + Sleep_ is still good_sleep, not tropical_night_mode, on this board (see + airconditioner.CLIMATE).""" reg, resources = _resolve('airconditioner_artik051_krac_18k') state = flatten( discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'beep' not in state + assert 'buzzer_volume' not in state assert 'tropical_night_mode' not in state - assert state['buzzer_volume'] == 100.0 + assert state['beep'] is True assert state['good_sleep'] == 0.0 + + +def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number(): + """Issue #136: three real units across two reporters only ever reported + Volume_100 or Volume_Mute -- never an intermediate value -- and the old + buzzer_volume Number's write path (a plain integer string) could never + produce the literal 'Mute' token needed to turn it off. 'beep' now + applies uniformly across board generations instead of being gated off + the legacy board.""" + reg, resources = _resolve('airconditioner_artik051_krac_18k') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['beep'] is True + + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + o.replace('Volume_100', 'Volume_Mute') + for o in resources['/mode/vs/0']['x.com.samsung.da.options'] + ] + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['beep'] is False + + path, body = airconditioner._beep_write('Off', resources['/mode/vs/0']) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['Volume_Mute']} + + +def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale(): + """Issue #193: this legacy ARTIK051 board reports cumulativePower in + centiwatt-hours (raw '117430000'), not the plain Wh common.wh_to_kwh + assumes -- confirmed against the reporter's own SmartThings-app reading + of 1,174.30 kWh. ENERGY_METER_LEGACY's /100000 scale must produce that + exact value, not the /1000-only 117430.0 the generic capability would.""" + reg, resources = _resolve('airconditioner_artik051_krac_energy') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['energy_kwh'] == 1174.3 + + +def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale(): + """The ENERGY_METER_GENERIC/LEGACY split must not change behavior for + every other AC board generation -- same value as plain wh_to_kwh.""" + reg, resources = _ac() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['energy_kwh'] == round(1686632 / 1000.0, 2) diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 1d21435..3828557 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -238,6 +238,19 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_airconditioner_via_cac_token(self): + """Issue #191: a cassette AC (TP1X_DA-AC-CAC-01001_0000) regressed to + 'unknown'/common-caps in 0.16.0 when oneUiVersion detection was + dropped -- 'CAC' had never been added to the modelNum board-token + table, only reachable before via oneUiVersion's '7.0 Air conditioner'.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00', + 'TP1X_DA-AC-CAC-01001_0000', + ) + assert reg is not None + assert reg.name == 'airconditioner' + def test_dehumidifier_via_dhm_token(self): """Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board family with the room-AC models but reports no oneUiVersion and diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index d536165..37218cf 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -152,6 +152,14 @@ class TestPowerFallback: assert switch.exists_fn(resources['/power/0'], resources) is True assert sensor.exists_fn(resources['/power/0'], resources) is False + def test_polled_warm_so_power_state_reflects_quickly(self): + """issue #56 follow-up: neither href had a poll_tier, so power state + only ever refreshed on the once-per-30s summary poll instead of the + subscribe/subpoll cadence 'warm'/'hot' hrefs get -- same reasoning + as REMOTE_CONTROL_GENERIC/VS_FALLBACK's own 'warm' tier.""" + assert common.POWER_GENERIC.poll_tier == 'warm' + assert common.POWER_VS_FALLBACK.poll_tier == 'warm' + class TestWmSetinfoFlags: def test_washer_fixture_carries_setinfo_from_device0(self): diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 3b6399e..d8d6d80 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -230,6 +230,44 @@ class TestPantryZone: assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} +class TestDefiniteTemperatureCooler: + """Discrete cooler setpoint (RT42DG6630B1FZ, issue #186) -- a single-door + fridge whose /temperature/definite/cooler/vs/0 falls outside both + TEMP_CURRENT_GENERIC and TEMP_SETPOINT's href prefixes, so temperature + control was entirely unbound before this capability was added.""" + + def test_href(self): + assert fridge.DEFINITE_TEMPERATURE_COOLER.href == '/temperature/definite/cooler/vs/0' + + def test_write(self): + desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0] + path, body = desc.write_fn('3', {}) + assert path == ['temperature', 'definite', 'cooler', 'vs', '0'] + assert body == {'x.com.samsung.da.definite.desired': '3'} + + def test_no_unbound_hrefs_and_discrete_options(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_definite_cooler') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + + state = flatten(bound, resources) + assert state['cooler_temperature_setpoint'] == '2' + + rep = resources['/temperature/definite/cooler/vs/0'] + assert rep['x.com.samsung.da.definite.supportedList'] == ['1', '2', '3', '4', '7'] + + class TestKimchiZone: """Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) -- top/middle/bottom each report an identically-shaped currentMode/ diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 209a1a2..6510c5d 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,103 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): + """TP1X_DA-AC-CAC-01001_0000 (issue #191) -- fell back to 'unknown' in + 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never + been added to the modelNum board-token table. Resolved via the new 'CAC' + token onto the existing airconditioner registry. Not fully covered yet -- + ten hrefs remain unbound (edge lighting, PM1 filter, stateful light, + absence-clean, four sound-settings resources, smart-sensing-cooling, UV + LED), all genuinely new to this board generation and out of scope for + the routing fix; see test_airconditioner_cac.py for the documented gap.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_cac') + golden = json.loads((GOLDEN / 'airconditioner_cac.json').read_text()) + state_keys = _new_state_keys('airconditioner_cac', 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_avt_ww(): + """AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board; + reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a + whole token doesn't match this board's 'AVT'/'WW' split. Resolved via the + new 'AVT' modelNum board-token fallback into the existing air_purifier + registry. Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier_avt_ww') + golden = json.loads((GOLDEN / 'air_purifier_avt_ww.json').read_text()) + state_keys = _new_state_keys('air_purifier_avt_ww', 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_artik051_krac_energy(): + """AR12NXWXCWKNEU/ARTIK051_KRAC_18K (issue #193) -- same legacy board + generation as the artik051_krac_18k fixture, but this dump has a nonzero + cumulativePower. Locks in state_keys; the actual /100000 scale fix is + asserted separately in test_airconditioner_capabilities.py since golden + only compares key sets, not values.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_artik051_krac_energy') + golden = json.loads( + (GOLDEN / 'airconditioner_artik051_krac_energy.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_artik051_krac_energy', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + +def test_registry_reproduces_golden_state_keys_for_refrigerator_definite_cooler(): + """RT42DG6630B1FZ (issue #186) -- a single-door "cooler only" fridge whose + /temperature/definite/cooler/vs/0 doesn't match either + TEMP_CURRENT_GENERIC's '/temperature/current/' or TEMP_SETPOINT's + '/temperature/desired/' href prefix, so temperature control was entirely + unbound. Resolved via the new DEFINITE_TEMPERATURE_COOLER capability + (a select over the device's own discrete supportedList, not a + continuous NumberDesc range -- 5 and 6 aren't valid setpoints on this + model). Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_definite_cooler') + golden = json.loads( + (GOLDEN / 'refrigerator_definite_cooler.json').read_text() + ) + state_keys = _new_state_keys('refrigerator_definite_cooler', 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_ne6516a(): + """NE6516A-class range (issue #183) -- no /information/vs/0, resolved via + the same 'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature as + the other no-info range fixtures. Its /mode/vs/0 options[] carries + EnergySaving_On and BurnerOnAlert_Off (previously unbound entirely) but + no fastpreheat_*/NaturalSteam_* tokens at all -- locks in that those two + switches now correctly stay unbound instead of binding as always-off, + does-nothing phantom controls.""" + from tests.conftest import _load_device + resources = _load_device('range_ne6516a') + golden = json.loads((GOLDEN / 'range_ne6516a.json').read_text()) + state_keys = _new_state_keys('range_ne6516a', 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_range_ne6516a_capabilities.py b/tests/test_range_ne6516a_capabilities.py new file mode 100644 index 0000000..ccf905c --- /dev/null +++ b/tests/test_range_ne6516a_capabilities.py @@ -0,0 +1,62 @@ +"""Tests for the NE6516A-class range (issue #183). + +Same no-/information/vs/0, no-burner-status shape as issues #74/#138. Its +/mode/vs/0 options[] carries EnergySaving_On and BurnerOnAlert_Off -- both +previously unbound entirely -- but no fastpreheat_*/NaturalSteam_* tokens at +all, which is what exposed the pre-existing fast_preheat/natural_steam +phantom-switch bug (both were shipped with no exists_fn, so they bound +unconditionally, always read off, and any write went to a token this +firmware never recognized). +""" +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.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range_ne6516a') + 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_fast_preheat_and_natural_steam_absent_without_their_tokens(): + """Neither token is in this dump's options[] -- both switches must stay + unbound rather than silently reading as an always-off phantom control.""" + state = _state() + assert 'fast_preheat' not in state + assert 'natural_steam' not in state + + +def test_energy_saving_and_cooktop_alert_present(): + state = _state() + assert state['energy_saving'] is True + assert state['cooktop_on_alert'] is False + + +def test_child_lock_already_reads_correctly(): + """issue #183 also reported child lock as unreadable -- but this dump's + /kidslock/vs/0 ('Run') and /doors/vs/0's door lock field ('Lock') already + agree the door is locked, and the existing common.KIDS_LOCK_VS_FALLBACK + already reads that correctly with no code change needed.""" + state = _state() + assert state['child_lock'] is True