diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json index 350c0f7..557fb67 100644 --- a/custom_components/localthings/manifest.json +++ b/custom_components/localthings/manifest.json @@ -12,5 +12,5 @@ "pyOpenSSL>=23.0", "smartthings-local>=0.1.2" ], - "version": "0.20.0" + "version": "0.21.0" } diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 5c1b98f..063d739 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -225,13 +225,16 @@ _OIC_TYPE_TO_KEY: dict[str, str] = { "oic.d.dishwasher": "dishwasher", "oic.d.dryer": "dryer", "oic.d.oven": "oven", + "oic.d.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0 "oic.d.refrigerator": "refrigerator", + "oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator "oic.d.washer": "washer", "x.com.st.d.airqualitysensor": "air_monitor", "x.com.st.d.dehumidifier": "dehumidifier", "x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON "x.com.st.d.stickcleaner": "vacuum_station", "x.com.st.d.steamcloset": "air_dresser", + "x.com.st.d.winecellar": "refrigerator", # issue #328 -- same TP1X_REF_21K board } diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index bd3e52c..29af8ce 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -14,6 +14,10 @@ REGISTRY = DeviceRegistry( fridge.DOOR_ALERT, common.WATER_FILTER, fridge.AIR_FILTER, + fridge.DEODOR_FILTER, + fridge.AUTO_DOOR_TIMER, + fridge.WINECELLAR_PANTRY_ZONE, + fridge.WINECELLAR_INFO, dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, @@ -44,5 +48,6 @@ REGISTRY = DeviceRegistry( fridge.DOOR_GENERIC, fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC, + fridge.AUTO_DOOR_VARIANT, ], ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 0f0d32b..4eac2be 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -22,7 +22,7 @@ from ..entities import ( SwitchDesc, ) from . import common -from .common import filter_usage_percent, normalize_temp_unit +from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -854,16 +854,17 @@ AIR_FILTER = Capability( icon="mdi:air-filter", entity_category="diagnostic", ), - # Lifetime hour counter, resets only on filter replacement. + # Lifetime hour counter, resets only on filter replacement. Derived + # from the percentage and filterCapacity (issue #330) -- filterUsage + # itself is not an hour count. SensorDesc( key="air_filter_usage_hours", - field="x.com.samsung.da.filterUsage", + rep_fn=filter_usage_hours, device_class="duration", state_class="total_increasing", unit_fn=_filter_unit, icon="mdi:air-filter", entity_category="diagnostic", - value_fn=_int, ), # Locally writable alarm threshold (see _threshold_write); only # surfaces where supportedFilterDesiredUsage is advertised. @@ -925,13 +926,12 @@ AIR_FILTER_PM1 = Capability( ), SensorDesc( key="air_filter_pm1_usage_hours", - field="x.com.samsung.da.filterUsage", + rep_fn=filter_usage_hours, device_class="duration", state_class="total_increasing", unit_fn=_filter_unit, icon="mdi:air-filter", entity_category="diagnostic", - value_fn=_int, exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"), ), SelectDesc( diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 56c9166..17a3057 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -72,15 +72,25 @@ def epoch_to_utc(value): def filter_usage_percent(rep): - """Filter usage as a percentage of rated capacity. Several families - report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of - a 500-hour capacity), so a plain value with a '%' unit would be wrong. - Returns None when capacity is missing/zero.""" - used = _num(rep.get("x.com.samsung.da.filterUsage")) + """Filter usage as a percentage. `filterUsage` is already 0-100 on every + family confirmed so far, including ARTIK051_PRAC (issue #330): its own + fixture and three live heads all show `filterStatus == 'wash'` at + `filterUsage == '100'` regardless of `filterCapacity` (60/224/500 across + other families' fixtures), which only holds if `filterUsage` is already + a percent -- dividing by capacity again would read that filter as fresh + at 20%.""" + return int_or_none(rep.get("x.com.samsung.da.filterUsage")) + + +def filter_usage_hours(rep): + """Elapsed filter hours, derived from the percentage and capacity rather + than read off `filterUsage` directly -- `filterUsage` is a percent, not + an hour count (issue #330). Returns None when capacity is missing/zero.""" + pct = filter_usage_percent(rep) cap = _num(rep.get("x.com.samsung.da.filterCapacity")) - if used is None or not cap: + if pct is None or not cap: return None - return round(used / cap * 100) + return round(pct / 100 * cap, 1) def normalize_temp_unit(raw, default="°F"): diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 52a42e5..ef82886 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -326,9 +326,154 @@ STATUS_LOCK = Capability( value_fn=lambda v: v == "On", write_fn=_status_lock_write("x.com.samsung.da.device.sound"), ), + # Auto Door Open's own voice/sound feedback toggles (issue #328, + # TP1X_REF_21K family) -- siblings of auto_door_opener above, not + # duplicates of fridge_sound (device.sound is the general appliance + # beep, these two gate ado's own prompts). Only seen on the + # auto-door-equipped variants (single/kimchi/winecellar), not the + # earlier TP1X_REF_21K dumps that predate that feature -- gated on + # each field's own presence rather than assumed universal. + SwitchDesc( + key="auto_door_voice_control", + field="x.com.samsung.da.ado.voicecontrol", + icon="mdi:microphone", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_status_lock_write("x.com.samsung.da.ado.voicecontrol"), + exists_fn=lambda rep, resources: "x.com.samsung.da.ado.voicecontrol" in rep, + ), + SwitchDesc( + key="auto_door_sound_control", + field="x.com.samsung.da.ado.soundcontrol", + icon="mdi:volume-medium", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_status_lock_write("x.com.samsung.da.ado.soundcontrol"), + exists_fn=lambda rep, resources: "x.com.samsung.da.ado.soundcontrol" in rep, + ), ), ) +# Auto Door Open's paired delay setting (issue #328, TP1X_REF_21K family): +# how long the door stays held open before it re-closes, on/off itself is +# STATUS_LOCK.auto_door_opener above. Same discrete-options-select shape as +# DEFINITE_TEMPERATURE_COOLER/FREEZER. Unit unconfirmed -- no supportedList +# field states it and the report carried no app screenshot -- so this is a +# raw-code select rather than a guessed seconds/minutes NumberDesc. + + +def _auto_door_timer_write(p, rep, href=None): + return ["autodoor", "timer", "vs", "0"], {"x.com.samsung.da.time.desired": p} + + +AUTO_DOOR_TIMER = Capability( + href="/autodoor/timer/vs/0", + poll_tier="warm", + entities=( + SelectDesc( + key="auto_door_timer", + field="x.com.samsung.da.time.desired", + icon="mdi:timer-outline", + translation_key="auto_door_timer", + entity_category="config", + options_field="x.com.samsung.da.time.supportedOptions", + write_fn=_auto_door_timer_write, + ), + ), +) + +# /autodoor//vs/0 -- one per fridge sub-type sharing the Auto Door +# Open feature (single-door, kimchi, winecellar seen so far; issue #328). +# Each reports only x.com.samsung.da.ado.openOptions, declaring which open +# styles that variant supports -- every dump seen so far carries exactly +# one option ('Single') with no paired desired/current field to make a +# choice against, the same "no real choice to expose yet" shape as +# ignored.py's /mode/0. Bound with no entities to record coverage; revisit +# if a device ever reports more than one option. +# +# A pattern cap rather than one entry per variant: match_fn (not just the +# prefix) is what actually gates this, so a future variant href needs no +# code change to stay covered, and /autodoor/timer/vs/0's own exact-href +# AUTO_DOOR_TIMER above always wins for that href regardless (discover() +# only falls through to pattern caps when no exact cap matched). This is +# registry-scoped, not global -- unlike ignored.IGNORED, the unknown- +# device-type fallback never reaches it, so the prefix caveat in +# ignored.py's own docstring doesn't apply here. +AUTO_DOOR_VARIANT = Capability( + href=None, + href_prefix="/autodoor/", + match_fn=lambda rep, resources: "x.com.samsung.da.ado.openOptions" in rep, +) + +# Wine-cellar variant (x.com.st.d.winecellar, issue #328) of the same +# deodorizing filter AIR_FILTER models -- filterUsage/filterStatus at a +# different href, same 0-100-percentage-already shape (see AIR_FILTER's own +# comment). filterUsage reads '-1' on the only dump seen (filterStatus +# 'normal'), relayed as-is rather than special-cased -- no second dump to +# confirm whether that's a real sentinel or this unit just not tracking it. +# Own 'deodor_'-prefixed keys rather than reusing AIR_FILTER.entities +# verbatim -- same collision AIR_FILTER's own 'air_' prefix was chosen to +# avoid against WATER_FILTER, and both filters are plausible on one unit +# (this device's own board reports an internal air filter on other +# TP1X_REF_21K variants). +DEODOR_FILTER = Capability( + href="/filter/deodorfilter/vs/0", + poll_tier="cold", + entities=( + SensorDesc( + key="deodor_filter_usage", + field="x.com.samsung.da.filterUsage", + unit="%", + state_class="measurement", + icon="mdi:air-filter", + entity_category="diagnostic", + value_fn=int_or_none, + ), + SensorDesc( + key="deodor_filter_status", + field="x.com.samsung.da.filterStatus", + device_class="enum", + options=("normal", "wash", "replace"), + translation_key="filter_status", + icon="mdi:air-filter", + entity_category="diagnostic", + value_fn=lambda v: v.lower() if isinstance(v, str) else v, + ), + ), +) + +# Wine-cellar multi-compartment pantry select (issue #328): same +# mode/supportedOptions shape as PANTRY_ZONE, at its own href with a 5-way +# option set (Processed_Meat/Cheese/Nuts/Fruit/Wine) instead of PANTRY_ZONE's. +# Only a "one" instance seen -- not generalized to a pattern cap, same +# discipline as PANTRY_ZONE itself. + + +def _winecellar_pantry_write(p, rep, href=None): + return ["status", "winecellar", "pantry", "one", "vs", "0"], {"x.com.samsung.da.mode": p} + + +WINECELLAR_PANTRY_ZONE = Capability( + href="/status/winecellar/pantry/one/vs/0", + poll_tier="warm", + entities=( + SelectDesc( + key="winecellar_pantry_zone_mode", + field="x.com.samsung.da.mode", + icon="mdi:glass-wine", + translation_key="winecellar_pantry_zone_mode", + entity_category="config", + options_field="x.com.samsung.da.supportedOptions", + write_fn=_winecellar_pantry_write, + ), + ), +) + +# Wine-cellar internal table-revision marker (issue #328) -- versioning +# metadata, not appliance state. Same treatment as ignored.py's +# /wm/setinfo/vs/0. +WINECELLAR_INFO = Capability(href="/information/winecellar/vs/0") + # /defrost/delay/vs/0 is the writable toggle to postpone a scheduled # defrost. /defrost/block/vs/0 is unrelated: despite the "block" naming, # live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index e83ea00..977b8db 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -225,6 +225,9 @@ "air_filter_threshold": { "name": "Práh upozornění na filtr" }, + "auto_door_timer": { + "name": "Časovač automatického otevírání dvířek" + }, "beverage_zone_mode": { "name": "Režim zóny nápojů", "state": { @@ -472,9 +475,11 @@ "kimchi_storage_crunfch": "Křupavé kimči", "kimchi_storage_buy": "Kupované kimči", "storage_fridge_normal": "Chlazení", + "storage_fridge": "Chlazení", "storage_fridge_cold": "Chlazení, silné", "storage_fridge_warm": "Chlazení, slabé", "storage_freezer_normal": "Mražení", + "storage_freezer": "Mražení", "storage_freezer_cold": "Mražení, silné", "storage_freezer_warm": "Mražení, slabé", "kimchi_ripe_low_temp": "Zrání kimči, nízká teplota", @@ -487,7 +492,8 @@ "storage_fresh_cereal": "Obiloviny", "storage_fridge_drink": "Nápoje", "storage_fresh_wine": "Víno", - "storage_fresh_potato_banana": "Brambory a banány" + "storage_fresh_potato_banana": "Brambory a banány", + "newmode_kimchi_0000": "Nový režim" } }, "pantry_zone_mode": { @@ -498,6 +504,16 @@ "fdr_drinks": "Nápoje" } }, + "winecellar_pantry_zone_mode": { + "name": "Režim spíže", + "state": { + "processed_meat": "Zpracované maso", + "cheese": "Sýr", + "nuts": "Ořechy", + "fruit": "Ovoce", + "wine": "Víno" + } + }, "range_burner_power_level": { "name": "Výkon hořáku {number}", "state": { @@ -745,6 +761,9 @@ "air_filter_usage_hours": { "name": "Hodiny využití filtru" }, + "deodor_filter_usage": { + "name": "Využití filtru" + }, "air_quality_standard": { "name": "Norma kvality vzduchu" }, @@ -1147,6 +1166,12 @@ "auto_door_opener": { "name": "Automatické otevírání dvířek" }, + "auto_door_sound_control": { + "name": "Zvuk automatického otevírání dvířek" + }, + "auto_door_voice_control": { + "name": "Hlasové ovládání automatického otevírání dvířek" + }, "auto_release_dry": { "name": "Automatické uvolnění při sušení" }, diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 910e2d8..cf31314 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -225,6 +225,9 @@ "air_filter_threshold": { "name": "Filteralarm-Schwellenwert" }, + "auto_door_timer": { + "name": "Timer für automatischen Türöffner" + }, "beverage_zone_mode": { "name": "Getränkezonen-Modus", "state": { @@ -467,6 +470,16 @@ "fdr_drinks": "Getränke" } }, + "winecellar_pantry_zone_mode": { + "name": "Vorratszonen-Modus", + "state": { + "processed_meat": "Wurst/Aufschnitt", + "cheese": "Käse", + "nuts": "Nüsse", + "fruit": "Obst", + "wine": "Wein" + } + }, "range_burner_power_level": { "name": "Brenner {number} Leistungsstufe", "state": { @@ -678,9 +691,11 @@ "kimchi_storage_crunfch": "Knackiges Kimchi", "kimchi_storage_buy": "Gekauftes Kimchi", "storage_fridge_normal": "Kühlen", + "storage_fridge": "Kühlen", "storage_fridge_cold": "Kühlen, stark", "storage_fridge_warm": "Kühlen, schwach", "storage_freezer_normal": "Gefrieren", + "storage_freezer": "Gefrieren", "storage_freezer_cold": "Gefrieren, stark", "storage_freezer_warm": "Gefrieren, schwach", "kimchi_ripe_low_temp": "Kimchi-Reifung, niedrige Temperatur", @@ -693,7 +708,8 @@ "storage_fresh_cereal": "Getreide", "storage_fridge_drink": "Getränke", "storage_fresh_wine": "Wein", - "storage_fresh_potato_banana": "Kartoffeln und Bananen" + "storage_fresh_potato_banana": "Kartoffeln und Bananen", + "newmode_kimchi_0000": "Neuer Modus" } }, "energy_saving_mode": { @@ -745,6 +761,9 @@ "air_filter_usage_hours": { "name": "Filternutzungsstunden" }, + "deodor_filter_usage": { + "name": "Filternutzung" + }, "air_quality_standard": { "name": "Luftqualitätsstandard" }, @@ -1144,6 +1163,12 @@ "auto_door_opener": { "name": "Automatischer Türöffner" }, + "auto_door_sound_control": { + "name": "Ton des automatischen Türöffners" + }, + "auto_door_voice_control": { + "name": "Sprachsteuerung des automatischen Türöffners" + }, "auto_release_dry": { "name": "Automatische Trocknungsfreigabe" }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 9830e8f..5b684be 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -225,6 +225,9 @@ "air_filter_threshold": { "name": "Filter alarm threshold" }, + "auto_door_timer": { + "name": "Auto door open timer" + }, "beverage_zone_mode": { "name": "Beverage zone mode", "state": { @@ -474,9 +477,11 @@ "storage_fridge_normal": "Fridge", "storage_fridge_cold": "Fridge, strong", "storage_fridge_warm": "Fridge, weak", + "storage_fridge": "Fridge", "storage_freezer_normal": "Freezer", "storage_freezer_cold": "Freezer, strong", "storage_freezer_warm": "Freezer, weak", + "storage_freezer": "Freezer", "kimchi_ripe_low_temp": "Kimchi ripening, low temperature", "kimchi_ripe_normal_temp": "Kimchi ripening, room temperature", "kimchi_ripe_kkakdugi": "Kkakdugi ripening", @@ -487,7 +492,8 @@ "storage_fresh_cereal": "Grains", "storage_fridge_drink": "Beverages", "storage_fresh_wine": "Wine", - "storage_fresh_potato_banana": "Potato & banana" + "storage_fresh_potato_banana": "Potato & banana", + "newmode_kimchi_0000": "New mode" } }, "pantry_zone_mode": { @@ -498,6 +504,16 @@ "fdr_drinks": "Drinks" } }, + "winecellar_pantry_zone_mode": { + "name": "Pantry zone mode", + "state": { + "processed_meat": "Processed meat", + "cheese": "Cheese", + "nuts": "Nuts", + "fruit": "Fruit", + "wine": "Wine" + } + }, "range_burner_power_level": { "name": "Burner {number} power level", "state": { @@ -745,6 +761,9 @@ "air_filter_usage_hours": { "name": "Filter usage hours" }, + "deodor_filter_usage": { + "name": "Filter usage" + }, "air_quality_standard": { "name": "Air quality standard" }, @@ -1147,6 +1166,12 @@ "auto_door_opener": { "name": "Auto door opener" }, + "auto_door_sound_control": { + "name": "Auto door sound" + }, + "auto_door_voice_control": { + "name": "Auto door voice control" + }, "auto_release_dry": { "name": "Auto release dry" }, diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index dceaa23..f4b7d2a 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -374,6 +374,9 @@ "air_filter_threshold": { "name": "Umbral de alarma del filtro" }, + "auto_door_timer": { + "name": "Temporizador de apertura automática de puerta" + }, "beverage_zone_mode": { "name": "Modo de zona de bebidas", "state": { @@ -621,9 +624,11 @@ "kimchi_storage_crunfch": "Kimchi crujiente", "kimchi_storage_buy": "Kimchi comprado", "storage_fridge_normal": "Frigorífico", + "storage_fridge": "Frigorífico", "storage_fridge_cold": "Frigorífico, fuerte", "storage_fridge_warm": "Frigorífico, suave", "storage_freezer_normal": "Congelador", + "storage_freezer": "Congelador", "storage_freezer_cold": "Congelador, fuerte", "storage_freezer_warm": "Congelador, suave", "kimchi_ripe_low_temp": "Maduración kimchi, baja temperatura", @@ -636,7 +641,8 @@ "storage_fresh_cereal": "Cereales", "storage_fridge_drink": "Bebidas", "storage_fresh_wine": "Vino", - "storage_fresh_potato_banana": "Patata y plátano" + "storage_fresh_potato_banana": "Patata y plátano", + "newmode_kimchi_0000": "Modo nuevo" } }, "pantry_zone_mode": { @@ -647,6 +653,16 @@ "fdr_drinks": "Bebidas" } }, + "winecellar_pantry_zone_mode": { + "name": "Modo de zona de despensa", + "state": { + "processed_meat": "Embutidos", + "cheese": "Queso", + "nuts": "Frutos secos", + "fruit": "Fruta", + "wine": "Vino" + } + }, "range_burner_power_level": { "name": "Nivel de potencia del fuego {number}", "state": { @@ -891,6 +907,9 @@ "air_filter_usage_hours": { "name": "Horas de uso del filtro" }, + "deodor_filter_usage": { + "name": "Uso del filtro" + }, "air_quality_standard": { "name": "Estándar de calidad del aire" }, @@ -1296,6 +1315,12 @@ "auto_door_opener": { "name": "Apertura automática de puerta" }, + "auto_door_sound_control": { + "name": "Sonido de apertura automática de puerta" + }, + "auto_door_voice_control": { + "name": "Control por voz de apertura automática de puerta" + }, "auto_release_dry": { "name": "Liberación automática de secado" }, diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 85d5248..f13c398 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -225,6 +225,9 @@ "air_filter_threshold": { "name": "Soglia allarme filtro" }, + "auto_door_timer": { + "name": "Timer apertura automatica sportello" + }, "beverage_zone_mode": { "name": "Modalità zona bevande", "state": { @@ -472,9 +475,11 @@ "kimchi_storage_crunfch": "Kimchi croccante", "kimchi_storage_buy": "Kimchi acquistato", "storage_fridge_normal": "Frigorifero", + "storage_fridge": "Frigorifero", "storage_fridge_cold": "Frigorifero, forte", "storage_fridge_warm": "Frigorifero, debole", "storage_freezer_normal": "Congelatore", + "storage_freezer": "Congelatore", "storage_freezer_cold": "Congelatore, forte", "storage_freezer_warm": "Congelatore, debole", "kimchi_ripe_low_temp": "Maturazione kimchi, bassa temperatura", @@ -487,7 +492,8 @@ "storage_fresh_cereal": "Cereali", "storage_fridge_drink": "Bevande", "storage_fresh_wine": "Vino", - "storage_fresh_potato_banana": "Patate e banane" + "storage_fresh_potato_banana": "Patate e banane", + "newmode_kimchi_0000": "Nuova modalità" } }, "pantry_zone_mode": { @@ -498,6 +504,16 @@ "fdr_drinks": "Bevande" } }, + "winecellar_pantry_zone_mode": { + "name": "Modalità zona dispensa", + "state": { + "processed_meat": "Salumi", + "cheese": "Formaggio", + "nuts": "Frutta secca", + "fruit": "Frutta", + "wine": "Vino" + } + }, "range_burner_power_level": { "name": "Livello potenza fornello {number}", "state": { @@ -745,6 +761,9 @@ "air_filter_usage_hours": { "name": "Ore di utilizzo filtro" }, + "deodor_filter_usage": { + "name": "Utilizzo filtro" + }, "air_quality_standard": { "name": "Standard qualità aria" }, @@ -1147,6 +1166,12 @@ "auto_door_opener": { "name": "Apertura automatica sportello" }, + "auto_door_sound_control": { + "name": "Suono apertura automatica sportello" + }, + "auto_door_voice_control": { + "name": "Controllo vocale apertura automatica sportello" + }, "auto_release_dry": { "name": "Apertura automatica per asciugatura" }, diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 3fed8be..a27a9ef 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -225,6 +225,9 @@ "air_filter_threshold": { "name": "필터 알림 기준" }, + "auto_door_timer": { + "name": "오토 오픈 도어 타이머" + }, "beverage_zone_mode": { "name": "음료 보관 모드", "state": { @@ -472,9 +475,11 @@ "kimchi_storage_crunfch": "아삭 김치", "kimchi_storage_buy": "구입 김치", "storage_fridge_normal": "냉장", + "storage_fridge": "냉장", "storage_fridge_cold": "냉장 강", "storage_fridge_warm": "냉장 약", "storage_freezer_normal": "냉동", + "storage_freezer": "냉동", "storage_freezer_cold": "냉동 강", "storage_freezer_warm": "냉동 약", "kimchi_ripe_low_temp": "김치 저온 숙성", @@ -487,7 +492,8 @@ "storage_fresh_cereal": "곡류", "storage_fridge_drink": "음료", "storage_fresh_wine": "와인", - "storage_fresh_potato_banana": "감자 및 바나나" + "storage_fresh_potato_banana": "감자 및 바나나", + "newmode_kimchi_0000": "새 모드" } }, "pantry_zone_mode": { @@ -498,6 +504,16 @@ "fdr_drinks": "음료" } }, + "winecellar_pantry_zone_mode": { + "name": "팬트리실 모드", + "state": { + "processed_meat": "가공육", + "cheese": "치즈", + "nuts": "견과류", + "fruit": "과일", + "wine": "와인" + } + }, "range_burner_power_level": { "name": "{number}번 화구 화력", "state": { @@ -745,6 +761,9 @@ "air_filter_usage_hours": { "name": "필터 사용 시간" }, + "deodor_filter_usage": { + "name": "필터 사용량" + }, "air_quality_standard": { "name": "공기질 기준" }, @@ -1147,6 +1166,12 @@ "auto_door_opener": { "name": "오토 오픈 도어" }, + "auto_door_sound_control": { + "name": "오토 오픈 도어 소리" + }, + "auto_door_voice_control": { + "name": "오토 오픈 도어 음성 안내" + }, "auto_release_dry": { "name": "자동 문 열림 건조" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c135796..839a6a6 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -225,6 +225,9 @@ "air_filter_threshold": { "name": "Filteralarmdrempel" }, + "auto_door_timer": { + "name": "Timer automatische deuropener" + }, "beverage_zone_mode": { "name": "Modus drankenzone", "state": { @@ -472,9 +475,11 @@ "kimchi_storage_crunfch": "Knapperige kimchi", "kimchi_storage_buy": "Gekochte kimchi", "storage_fridge_normal": "Koelkast", + "storage_fridge": "Koelkast", "storage_fridge_cold": "Koelkast, sterk", "storage_fridge_warm": "Koelkast, zwak", "storage_freezer_normal": "Vriezer", + "storage_freezer": "Vriezer", "storage_freezer_cold": "Vriezer, sterk", "storage_freezer_warm": "Vriezer, zwak", "kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur", @@ -487,7 +492,8 @@ "storage_fresh_cereal": "Granen", "storage_fridge_drink": "Dranken", "storage_fresh_wine": "Wijn", - "storage_fresh_potato_banana": "Aardappelen en bananen" + "storage_fresh_potato_banana": "Aardappelen en bananen", + "newmode_kimchi_0000": "Nieuwe modus" } }, "pantry_zone_mode": { @@ -498,6 +504,16 @@ "fdr_drinks": "Dranken" } }, + "winecellar_pantry_zone_mode": { + "name": "Modus voorraadzone", + "state": { + "processed_meat": "Vleeswaren", + "cheese": "Kaas", + "nuts": "Noten", + "fruit": "Fruit", + "wine": "Wijn" + } + }, "range_burner_power_level": { "name": "Vermogensniveau brander {number}", "state": { @@ -745,6 +761,9 @@ "air_filter_usage_hours": { "name": "Filterverbruik (uren)" }, + "deodor_filter_usage": { + "name": "Filterverbruik" + }, "air_quality_standard": { "name": "Luchtkwaliteitsnorm" }, @@ -1147,6 +1166,12 @@ "auto_door_opener": { "name": "Automatische deuropener" }, + "auto_door_sound_control": { + "name": "Geluid automatische deuropener" + }, + "auto_door_voice_control": { + "name": "Spraakbediening automatische deuropener" + }, "auto_release_dry": { "name": "Auto Release Dry" }, diff --git a/tests/conftest.py b/tests/conftest.py index b0054f4..aa466fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,7 +70,7 @@ class FakeCoapSession: pass -def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]): +def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list], device_types=()): """Run the *whole* subdevice-aware discovery pipeline against fixture data, HA-free -- mirrors exactly what LocalThingsCoordinator does across _enumerate_subdevices_blocking + _run_discovery (issue #177), so a test @@ -78,6 +78,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]): it. See the adding-device-support skill's section 2 for the plain (non-subdevice) equivalent this extends. + `device_types` is the master's own /oic/d `rt` (see + discover_partitioned's `oic_device_types` param) -- only needed for a + board with no /information/vs/0 at all to route from (issue #324's + range, whose modelNum-based fallback has nothing to read), so it + defaults to () for every fixture that resolves by board token instead. + Returns `(bound, materialized, skipped, full_resources, device_type_name)`: - `bound`: every BoundEntity, main + every materialized subdevice. - `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from @@ -101,6 +107,7 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]): candidates, resolve, CAPABILITIES, + oic_device_types=device_types, ) return bound, materialized, skipped, full_resources, device_type_name diff --git a/tests/fixtures/golden/range_tp1x_da_ks_range_0101x.json b/tests/fixtures/golden/range_tp1x_da_ks_range_0101x.json new file mode 100644 index 0000000..a6585fa --- /dev/null +++ b/tests/fixtures/golden/range_tp1x_da_ks_range_0101x.json @@ -0,0 +1,39 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_on_alert", + "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", + "subdevice1_cloud_connected", + "subdevice1_cook_time", + "subdevice1_current_temp_c", + "subdevice1_cycle_active", + "subdevice1_finish_time", + "subdevice1_machine_state", + "subdevice1_operation_time_minutes", + "subdevice1_oven_mode", + "subdevice1_oven_setpoint", + "subdevice1_oven_state", + "subdevice1_progress_percentage", + "subdevice1_sound", + "warming_center_state" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_autodoor.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_autodoor.json new file mode 100644 index 0000000..dd4c5ba --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_autodoor.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "ai_energy_level", + "alarm_code", + "auto_door_opener", + "auto_door_timer", + "auto_door_voice_control", + "defrost_active", + "door_onedoorfreezer_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "fridge_sound", + "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/golden/refrigerator_tp1x_ref_21k_kimchi.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_kimchi.json new file mode 100644 index 0000000..0f91cfd --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_kimchi.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "auto_door_opener", + "auto_door_timer", + "auto_door_voice_control", + "door_onedoorkimchi_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "fridge_sound", + "onedoor_mode", + "onedoor_rack_count", + "onedoor_ripening_remaining", + "onedoor_ripening_status", + "power_energy_kwh", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/refrigerator_winecellar.json b/tests/fixtures/golden/refrigerator_winecellar.json new file mode 100644 index 0000000..38702ae --- /dev/null +++ b/tests/fixtures/golden/refrigerator_winecellar.json @@ -0,0 +1,26 @@ +{ + "state_keys": [ + "alarm_code", + "auto_door_opener", + "auto_door_sound_control", + "auto_door_timer", + "auto_door_voice_control", + "cabinet_light_dim", + "cabinet_light_switch", + "deodor_filter_status", + "deodor_filter_usage", + "door_winecellar_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "fridge_sound", + "power_energy_kwh", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "winecellar_bottom_setpoint", + "winecellar_pantry_zone_mode", + "winecellar_top_setpoint" + ] +} \ No newline at end of file diff --git a/tests/fixtures/range_tp1x_da_ks_range_0101x_device.json b/tests/fixtures/range_tp1x_da_ks_range_0101x_device.json new file mode 100644 index 0000000..56b24b5 --- /dev/null +++ b/tests/fixtures/range_tp1x_da_ks_range_0101x_device.json @@ -0,0 +1,470 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/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", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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-08-07T15:56:50" + } + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "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": "false", + "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": "5", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "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": [ + "UpperBroil", + "UpperConvectionBake", + "UpperConvectionRoast" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NE9801T-/AA0", + "SettingPossible_7", + "meatprobe_disconnected", + "UpperLamp_Off", + "Sound_Off", + "AdjustingTemp_0", + "Sabbath_Off", + "EnergySaving_Off", + "BurnerOnAlert_Off" + ], + "x.com.samsung.da.defaultMode": "UpperConvectionBake", + "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\":\"UpperBroil\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Upper\",\"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\":\"UpperConvectionBake\",\"version\":\"0100\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Upper\",\"tempMinC\":\"80\",\"tempMaxC\":\"250\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"480\",\"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\":\"UpperConvectionRoast\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Upper\",\"tempMinC\":\"80\",\"tempMaxC\":\"250\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"480\",\"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\":\"LowerBake\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Lower\",\"tempMinC\":\"80\",\"tempMaxC\":\"250\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"480\",\"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\":\"LowerConvectionBake\",\"version\":\"0100\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Lower\",\"tempMinC\":\"80\",\"tempMaxC\":\"250\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"480\",\"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\":\"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": "Unlock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/cooktopmonitoring/vs/0", + "rep": { + "x.com.samsung.da.warmingCenterState": "Off", + "x.com.samsung.da.cooktopMonitoring": "0", + "x.com.samsung.da.cooktopRunningState": "Ready", + "supportedHoodLampStateList": [ + "Run", + "Ready" + ] + } + } + ], + "oic_res": [ + { + "di": "**REDACTED**", + "links": [ + { + "href": "/oic/sec/doxm", + "rt": [ + "oic.r.doxm" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/sec/pstat", + "rt": [ + "oic.r.pstat" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/d", + "rt": [ + "oic.wk.d", + "oic.d.range" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/oic/p", + "rt": [ + "oic.wk.p" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/EasySetupResURI", + "rt": [ + "oic.r.easysetup" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/WiFiConfResURI", + "rt": [ + "oic.wk.wifi" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/CoapCloudConfResURI", + "rt": [ + "oic.wk.cloudserver" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/DevConfResURI", + "rt": [ + "oic.wk.devconf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49155, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/sec/provisioninginfo", + "rt": [ + "x.com.samsung.provisioninginfo" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/sec/accesspointlist", + "rt": [ + "x.com.samsung.accesspointlist" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + } + ] + } + ], + "seeds": { + "/device/1": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/oven/vs/1", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/temperatures/vs/1", + "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": "5", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "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/1", + "rep": { + "x.com.samsung.da.supportedModes": [ + "LowerBake", + "LowerConvectionBake" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.defaultMode": "LowerConvectionBake", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/1", + "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": "/connected/vs/1", + "rep": { + "x.com.samsung.da.connected": "On" + } + } + ] + }, + "seeds_note": "device0 and oic_res are verbatim diagnostics captures (issue #324). The /device/1 seed batch is reconstructed, not captured live: the diagnostics report only carries the subdevice's already-canonicalized resources (subdevices[0].resources), so this reindexes each trailing '.../vs/0' href to '.../vs/1' per subdevices.Subdevice.to_actual's own indexed-sibling rule, rather than recording the raw wire response." +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_autodoor_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_autodoor_device.json new file mode 100644 index 0000000..27a453b --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_autodoor_device.json @@ -0,0 +1,343 @@ +{ + "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": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "-19", + "x.com.samsung.da.current": "-19", + "x.com.samsung.da.maximum": "-17", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "range": [ + -23.0, + -17.0 + ], + "units": "C", + "temperature": -19.0 + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "range": [ + -23.0, + -17.0 + ], + "units": "C", + "temperature": -19.0 + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ], + "x.com.samsung.da.supportedActions": [ + "Start" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "41042", + "x.com.samsung.da.cumulativePower": "466708", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "4", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativeSavedPower": "15759" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00168541|00080023001713104100000041010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "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": "RO6", + "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": "250422", + "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": "24101709, 23031302, 24062000, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/door/onedoorfreezer/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/refrigeration/0", + "rep": { + "defrost": false, + "rapidFreeze": false, + "rapidCool": false + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 2, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1441", + "x.com.samsung.da.start": "2026-08-06T00:34:47Z", + "x.com.samsung.da.realSaving": "On" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-23-COMMON", + "versions": [ + "20250422" + ], + "visVersion": "250422" + }, + { + "type": "Micom", + "modelId": "02800016854100168441", + "versions": [ + "24101709", + "23031302" + ], + "visVersion": "241017" + }, + { + "type": "Micom", + "modelId": "028070655341FFFFFFFF", + "versions": [ + "24062000", + "FFFFFFFF" + ], + "visVersion": "240620" + } + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.ado.voicecontrol": "Off", + "x.com.samsung.da.device.sound": "On" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1", + "2" + ] + } + }, + { + "href": "/autodoor/timer/vs/0", + "rep": { + "x.com.samsung.da.time.desired": "1", + "x.com.samsung.da.time.supportedOptions": [ + "1", + "2", + "3", + "4", + "5", + "6" + ] + } + }, + { + "href": "/autodoor/single/vs/0", + "rep": { + "x.com.samsung.da.ado.openOptions": [ + "Single" + ] + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "ble_ocf", + null + ], + "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": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_kimchi_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_kimchi_device.json new file mode 100644 index 0000000..7f454c5 --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_kimchi_device.json @@ -0,0 +1,332 @@ +{ + "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": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ], + "x.com.samsung.da.supportedActions": [ + "Start" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "53539", + "x.com.samsung.da.cumulativePower": "174590", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPower": "4", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_KIMCHI_OUTDOOR_CONTROL" + ], + "x.com.samsung.da.modes": [ + "KIMCHI_KIMCHI_STORAGE_NORMAL", + "KIMCHI_RIPE_REMAIN_[0]:[0]", + "KIMCHIT_BOX_COUNT_[0]", + "KIMCHIM_BOX_COUNT_[0]", + "KIMCHIB_BOX_COUNT_[0]", + "KIMCHI_BOX_COUNT_[8]" + ], + "x.com.samsung.da.supportedOptions": [ + "KIMCHI_KIMCHI_STORAGE_NORMAL_[0]:[0]", + "KIMCHI_KIMCHI_STORAGE_COLD_[0]:[0]", + "KIMCHI_KIMCHI_STORAGE_WARM_[0]:[0]", + "KIMCHI_KIMCHI_STORAGE_CRUNFCH_[0]:[0]", + "KIMCHI_KIMCHI_STORAGE_BUY_[0]:[0]", + "KIMCHI_STORAGE_FRIDGE_[0]:[0]", + "KIMCHI_STORAGE_FREEZER_[0]:[0]", + "KIMCHI_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]", + "KIMCHI_KIMCHI_RIPE_LOW_TEMP_[5]:[17]" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_KIMCHI_OUTDOOR_CONTROL" + ], + "modes": [ + "KIMCHI_KIMCHI_STORAGE_NORMAL", + "KIMCHI_RIPE_REMAIN_[0]:[0]", + "KIMCHIT_BOX_COUNT_[0]", + "KIMCHIM_BOX_COUNT_[0]", + "KIMCHIB_BOX_COUNT_[0]", + "KIMCHI_BOX_COUNT_[8]" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00168041|10010022011713004101800031010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "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": "CO0", + "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": "250422", + "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": "24101006, 23031302, 24102200, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "8", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close" + } + ] + } + }, + { + "href": "/door/onedoorkimchi/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.countryCode": "", + "x.com.samsung.da.region": "" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 2, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1441", + "x.com.samsung.da.start": "2026-08-06T00:34:47Z", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-23-COMMON", + "versions": [ + "20250422" + ], + "visVersion": "250422" + }, + { + "type": "Micom", + "modelId": "02120016804100168441", + "versions": [ + "24101006", + "23031302" + ], + "visVersion": "241010" + }, + { + "type": "Micom", + "modelId": "021270659241FFFFFFFF", + "versions": [ + "24102200", + "FFFFFFFF" + ], + "visVersion": "241022" + } + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.ado.voicecontrol": "Off", + "x.com.samsung.da.device.sound": "On" + } + }, + { + "href": "/status/kimchi/onedoor/vs/0", + "rep": { + "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL", + "x.com.samsung.da.ripeStatus": "Off", + "x.com.samsung.da.ripeRemaintime": "0", + "x.com.samsung.da.rackCount": "8", + "x.com.samsung.da.ripeTotaltime": "0", + "x.com.samsung.da.supportMode": [ + "KIMCHI_STORAGE_NORMAL", + "KIMCHI_STORAGE_COLD", + "KIMCHI_STORAGE_WARM", + "KIMCHI_STORAGE_CRUNFCH", + "KIMCHI_STORAGE_BUY", + "STORAGE_FRIDGE", + "STORAGE_FREEZER", + "KIMCHI_RIPE_NORMAL_TEMP", + "KIMCHI_RIPE_LOW_TEMP", + "NEWMODE_KIMCHI_0000", + "NEWMODE_KIMCHI_0000", + "NEWMODE_KIMCHI_0000" + ] + } + }, + { + "href": "/autodoor/timer/vs/0", + "rep": { + "x.com.samsung.da.time.desired": "1", + "x.com.samsung.da.time.supportedOptions": [ + "1", + "2", + "3", + "4", + "5", + "6" + ] + } + }, + { + "href": "/autodoor/kimchi/vs/0", + "rep": { + "x.com.samsung.da.ado.openOptions": [ + "Single" + ] + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "ble_ocf", + null + ], + "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": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_winecellar_device.json b/tests/fixtures/refrigerator_winecellar_device.json new file mode 100644 index 0000000..c790bda --- /dev/null +++ b/tests/fixtures/refrigerator_winecellar_device.json @@ -0,0 +1,466 @@ +{ + "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": "/autodoor/winecellar/vs/0", + "rep": { + "x.com.samsung.da.ado.openOptions": [ + "Single" + ], + "href": "/autodoor/winecellar/vs/0" + } + }, + { + "href": "/autodoor/timer/vs/0", + "rep": { + "x.com.samsung.da.time.desired": "1", + "x.com.samsung.da.time.supportedOptions": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "href": "/autodoor/timer/vs/0" + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On", + "href": "/bespoke/vs/0" + } + }, + { + "href": "/cabinet/light/total/vs/0", + "rep": { + "x.com.samsung.da.lightLevel": "100", + "x.com.samsung.da.lightResolution": "1", + "x.com.samsung.da.lightControl.off.include": "Off", + "x.com.samsung.da.lightControl": "Off", + "x.com.samsung.da.timeout.desired": "0", + "x.com.samsung.da.timeout.supportedList": [ + "0", + "15", + "30", + "60" + ], + "href": "/cabinet/light/total/vs/0", + "rt": [ + "x.com.samsung.da.cabinetlight" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/door/winecellar/vs/0", + "rep": { + "x.com.samsung.da.openState": "Close", + "href": "/door/winecellar/vs/0", + "rt": [ + "x.com.samsung.da.doorwinecellar" + ], + "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": "9", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1345", + "x.com.samsung.da.start": "2026-08-06T02:08:48Z", + "x.com.samsung.da.realSaving": "On", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "19073", + "x.com.samsung.da.instantaneousPower": "5", + "x.com.samsung.da.cumulativePower": "210483", + "x.com.samsung.da.cumulativeSavedPower": "15696", + "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": "+00:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/filter/deodorfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "-1", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ], + "x.com.samsung.da.filterStatus": "normal", + "href": "/filter/deodorfilter/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00146141|000B0020001513924100000011000000", + "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": "531", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "260619", + "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": "24102313, 2201030E", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.ado.voicecontrol": "Off", + "x.com.samsung.da.ado.soundcontrol": "On", + "href": "/status/lock/vs/0", + "rt": [ + "x.com.samsung.da.lockstatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "AIRFILTER_DISABLE" + ], + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "WINE_T_TOTRACK_[6]", + "WINE_T_BOTTLE_RACK_[7]", + "WINE_B_TOTRACK_[4]", + "WINE_B_BOTTLE_RACK_[7]", + "WINE_PRESENTATION_[6]", + "WINE_BTM_BOTTLE_[20]", + "WINE_PANTRY_BOTTLE_[5]" + ], + "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": "/temperature/desired/winecellar/top/vs/0", + "rep": { + "temperature": 13.0, + "range": [ + 4.0, + 18.0 + ], + "units": "C", + "href": "/temperature/desired/winecellar/top/vs/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/winecellar/bottom/vs/0", + "rep": { + "temperature": 7.0, + "range": [ + 4.0, + 18.0 + ], + "units": "C", + "href": "/temperature/desired/winecellar/bottom/vs/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "WineCellar-top", + "x.com.samsung.da.desired": "13", + "x.com.samsung.da.current": "13", + "x.com.samsung.da.maximum": "18", + "x.com.samsung.da.minimum": "4", + "x.com.samsung.da.unit": "Celsius" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "WineCellar-bottom", + "x.com.samsung.da.desired": "7", + "x.com.samsung.da.current": "7", + "x.com.samsung.da.maximum": "18", + "x.com.samsung.da.minimum": "4", + "x.com.samsung.da.unit": "Celsius" + } + ], + "href": "/temperatures/vs/0" + } + }, + { + "href": "/information/winecellar/vs/0", + "rep": { + "x.com.samsung.da.tbl.revision": "1", + "href": "/information/winecellar/vs/0" + } + }, + { + "href": "/status/winecellar/pantry/one/vs/0", + "rep": { + "x.com.samsung.da.mode": "Fruit", + "x.com.samsung.range": [ + "4", + "13" + ], + "x.com.samsung.da.room": "0x10", + "x.com.samsung.da.name": "MULTI_PANTRY", + "x.com.samsung.da.unit": "Celsius", + "x.com.samsung.da.supportedOptions": [ + "Processed_Meat", + "Cheese", + "Nuts", + "Fruit", + "Wine" + ], + "href": "/status/winecellar/pantry/one/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-RE2", + "versions": [ + "20260619" + ], + "visVersion": "260619" + }, + { + "type": "Micom", + "modelId": "02900014614100146441", + "versions": [ + "24102313", + "2201030E" + ], + "visVersion": "241023" + } + ] + } + }, + { + "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": "Home_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/test_air_purifier_avt_ww.py b/tests/test_air_purifier_avt_ww.py index 9a8fc00..784688b 100644 --- a/tests/test_air_purifier_avt_ww.py +++ b/tests/test_air_purifier_avt_ww.py @@ -59,5 +59,5 @@ def test_air_quality_and_filter_sensors_present(): assert state["dust"] == 10 assert state["fine_dust"] == 9 assert state["odor"] == 1 - assert state["hepa_filter_usage"] == 0 + assert state["hepa_filter_usage"] == 30 assert state["hepa_filter_status"] == "normal" diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index f4a719e..fd9b8c3 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -95,10 +95,13 @@ def test_power_and_convenient_folded_into_climate(): assert "convenient_mode" not in state -def test_air_filter_usage_is_percentage_of_capacity(): - """filterUsage is a raw count in the capacity unit (100 of 500), surfaced as - a percentage rather than the misleading raw value.""" - assert _state()["air_filter_usage"] == 20 +def test_air_filter_usage_is_already_a_percentage(): + """filterUsage is already 0-100, not a raw count to divide by + filterCapacity (issue #330): this fixture (ARTIK051_PRAC_20K) reports + filterStatus == 'wash' at filterUsage == '100', which only holds if + filterUsage is already a percent -- dividing by the 500 capacity again + would read a filter due for washing as 20% fresh.""" + assert _state()["air_filter_usage"] == 100 def test_climate_write_targets(): @@ -771,19 +774,34 @@ def test_tropical_night_state_levels_across_fixtures(): assert level("airconditioner_tp2x_rac_20k") == 16 -def test_air_filter_usage_hours_reads_raw_count(): - """filterUsage is a lifetime hour counter (41 of 500) that resets on - filter replacement -- total_increasing, not measurement. Unit comes from +def test_air_filter_usage_hours_derived_from_percent_and_capacity(): + """filterUsage is a percent, not an hour count (issue #330): the lifetime + hour reading is derived from percent * capacity rather than read off + filterUsage directly. total_increasing, not measurement; unit comes from filterCapacityUnit via unit_fn, not a hardcoded 'h'.""" desc = next( e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_usage_hours" and isinstance(e, SensorDesc) ) - assert desc.value_fn("41") == 41 - assert desc.value_fn(41) == 41 - assert desc.value_fn(None) is None - assert desc.value_fn("not-a-number") is None + assert desc.rep_fn is not None + assert ( + desc.rep_fn( + {"x.com.samsung.da.filterUsage": "91", "x.com.samsung.da.filterCapacity": "500"} + ) + == 455.0 + ) + assert ( + desc.rep_fn( + { + "x.com.samsung.da.filterUsage": "not-a-number", + "x.com.samsung.da.filterCapacity": "500", + } + ) + is None + ) + assert desc.rep_fn({"x.com.samsung.da.filterCapacity": "500"}) is None + assert desc.rep_fn({"x.com.samsung.da.filterUsage": "91"}) is None assert desc.device_class == "duration" assert desc.state_class == "total_increasing" assert desc.unit_fn is not None @@ -830,8 +848,8 @@ def test_air_filter_threshold_absent_without_supported_enum(): reg, resources = _ac_windfree() state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) assert "air_filter_threshold" not in state - assert state["air_filter_usage_hours"] == 41 - assert state["air_filter_usage"] == 8 # 41/500 -> 8% + assert state["air_filter_usage"] == 41 + assert state["air_filter_usage_hours"] == 205.0 # 41% of 500 def test_air_filter_threshold_binds_on_enum_board(): diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 1d453f0..a0ce829 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1132,12 +1132,16 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) -def _new_subdevice_aware_state_keys(name): +def _new_subdevice_aware_state_keys(name, device_types=()): """Like _new_state_keys, but runs the full subdevice-aware pipeline (enumerate_subdevices + discover_partitioned, issue #177) instead of a single discover() call, so the golden for a composite-device fixture captures every materialized subdevice's keys - (subdevice1_-/subdevice__-prefixed), not just the master's.""" + (subdevice1_-/subdevice__-prefixed), not just the master's. + + `device_types` threads through to discover_partitioned's own + `oic_device_types` -- needed for a board with no /information/vs/0 at + all to route from (issue #324's range).""" from custom_components.localthings.registry.adapter import flatten from tests.conftest import _discover_full, _load_device_full @@ -1146,6 +1150,7 @@ def _new_subdevice_aware_state_keys(name): resources, oic_res, seeds, + device_types, ) state = flatten(bound, full_resources) return sorted(state.keys()) @@ -1407,6 +1412,78 @@ def test_registry_reproduces_golden_state_keys_for_refrigerator_tp1x_ref_21k_def ) +def test_registry_reproduces_golden_state_keys_for_refrigerator_tp1x_ref_21k_autodoor(): + """TP1X_REF_21K, single-freezer-door Auto Door Open variant (issue + #328): /autodoor/single/vs/0 + /autodoor/timer/vs/0, plus + STATUS_LOCK's ado.voicecontrol sibling toggle.""" + from tests.conftest import _load_device + + resources = _load_device("refrigerator_tp1x_ref_21k_autodoor") + golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_autodoor.json").read_text()) + state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_autodoor", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + +def test_registry_reproduces_golden_state_keys_for_refrigerator_tp1x_ref_21k_kimchi(): + """Kimchi-refrigerator Auto Door Open variant (issue #328) -- resolves + via /oic/d's oic.d.krefrigerator, not modelNum (the board is the same + TP1X_REF_21K as the regular fridge, so only the OCF type tells them + apart before checking hrefs).""" + from tests.conftest import _load_device + + resources = _load_device("refrigerator_tp1x_ref_21k_kimchi") + golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_kimchi.json").read_text()) + state_keys = _new_state_keys( + "refrigerator_tp1x_ref_21k_kimchi", + resources, + device_types=("oic.wk.d", "oic.d.krefrigerator"), + ) + 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_winecellar(): + """Wine-cellar refrigerator variant (issue #328) -- resolves via /oic/d's + x.com.st.d.winecellar, same TP1X_REF_21K board as the other two. Adds + the deodorizing filter at its own href, the multi-compartment pantry + select, and the table-revision info resource.""" + from tests.conftest import _load_device + + resources = _load_device("refrigerator_winecellar") + golden = json.loads((GOLDEN / "refrigerator_winecellar.json").read_text()) + state_keys = _new_state_keys( + "refrigerator_winecellar", + resources, + device_types=("oic.wk.d", "x.com.st.d.winecellar"), + ) + 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_tp1x_da_ks_range_0101x(): + """Dual-cavity range (issue #324): no /information/vs/0 at all, so + routing depends entirely on /oic/d's oic.d.range -- and the second + cavity is a genuine Pattern A indexed subdevice at /device/1.""" + name = "range_tp1x_da_ks_range_0101x" + golden = json.loads((GOLDEN / f"{name}.json").read_text()) + state_keys = _new_subdevice_aware_state_keys(name, device_types=("oic.wk.d", "oic.d.range")) + 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 diff --git a/tests/test_range_tp1x_da_ks_range_0101x.py b/tests/test_range_tp1x_da_ks_range_0101x.py new file mode 100644 index 0000000..a665d9f --- /dev/null +++ b/tests/test_range_tp1x_da_ks_range_0101x.py @@ -0,0 +1,92 @@ +"""Dual-cavity range (TP1X_DA-KS-RANGE-0101X, NE63T8751SG/AA-class, issue +#324): no /information/vs/0 at all, so the modelNum-based fallback has +nothing to read -- routing depends entirely on /oic/d's own device type +(`oic.d.range`), read separately from the /device/0 batch (see +registry/identity.py and by_type/__init__.py's `_OIC_TYPE_TO_KEY`). + +The second oven cavity answers as an indexed sibling at /device/1 (Pattern +A, same mechanism as the AC family's issue #177 fixtures) -- the master's +/mode/vs/0 (defaultMode 'UpperConvectionBake') is the upper cavity, the +subdevice's canonical /mode/vs/0 (defaultMode 'LowerConvectionBake') is the +lower one. +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve +from tests.conftest import _discover_full, _load_device_full + +FIXTURE = "range_tp1x_da_ks_range_0101x" +DEVICE_TYPES = ("oic.wk.d", "oic.d.range") + + +def _discover(): + resources, oic_res, seeds = _load_device_full(FIXTURE) + return _discover_full(resources, oic_res, seeds, DEVICE_TYPES) + + +def test_resolves_via_oic_type(): + reg = for_device_by_oic_type(DEVICE_TYPES) + assert reg is not None and reg.name == "range" + + +def test_resolves_via_the_full_resolve_entrypoint(): + resources, _oic_res, _seeds = _load_device_full(FIXTURE) + reg = resolve(resources, device_types=DEVICE_TYPES) + assert reg is not None and reg.name == "range" + + +def test_no_unbound_hrefs(): + """Every resource in the issue #324 dump binds or is ignored, on both + the master (upper cavity) and the materialized second cavity -- clears + the coverage-gap repair.""" + resources, oic_res, seeds = _load_device_full(FIXTURE) + unbound = [] + from custom_components.localthings.registry.registry import CAPABILITIES + from custom_components.localthings.registry.subdevices import ( + discover_partitioned, + enumerate_subdevices, + ) + from tests.conftest import FakeCoapSession + + sess = FakeCoapSession(seeds) + candidates, extra = enumerate_subdevices(sess, resources, oic_res) + full_resources = {**resources, **extra} + discover_partitioned( + full_resources, + candidates, + resolve, + CAPABILITIES, + log=unbound.append, + oic_device_types=DEVICE_TYPES, + ) + assert unbound == [] + + +def test_second_cavity_materializes_as_an_indexed_subdevice(): + _bound, materialized, skipped, _full_resources, device_type_name = _discover() + assert device_type_name == "range" + assert skipped == [] + assert [(s.kind, s.key) for s in materialized] == [("indexed", "1")] + assert materialized[0].seed_path == ("device", "1") + + +def test_both_cavities_expose_distinct_oven_state(): + bound, _materialized, _skipped, full_resources, _name = _discover() + state = flatten(bound, full_resources) + for key in ("oven_mode", "oven_setpoint", "oven_state", "machine_state", "door_open"): + assert key in state, key + # Second cavity's entities carry the subdevice1_ prefix (adapter._key) -- + # the master's own keys stay unprefixed, so both cavities get distinct + # unique_ids rather than colliding on the same entity key. + assert "subdevice1_oven_mode" in state + assert "subdevice1_oven_setpoint" in state + + +def test_cooktop_monitoring_present_on_the_master_only(): + """The cooktop half belongs to the appliance as a whole, not either oven + cavity -- /cooktopmonitoring/vs/0 has no per-cavity index.""" + bound, _materialized, _skipped, full_resources, _name = _discover() + state = flatten(bound, full_resources) + assert "cooktop_running_state" in state + assert "warming_center_state" in state + assert "subdevice1_cooktop_running_state" not in state diff --git a/tests/test_refrigerator_tp1x_ref_21k_autodoor_capabilities.py b/tests/test_refrigerator_tp1x_ref_21k_autodoor_capabilities.py new file mode 100644 index 0000000..b5560cf --- /dev/null +++ b/tests/test_refrigerator_tp1x_ref_21k_autodoor_capabilities.py @@ -0,0 +1,80 @@ +"""Auto Door Open (issue #328, TP1X_REF_21K single-freezer-door variant). + +`/status/lock/vs/0`'s ado.devicecontrol switch was already modeled +(STATUS_LOCK.auto_door_opener); this dump adds the paired voice-feedback +toggle, the door-hold timer, and the per-variant capability declaration +href -- see fridge.py's AUTO_DOOR_TIMER/AUTO_DOOR_VARIANT comments. +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import fridge +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import SelectDesc +from tests.conftest import _load_device + +FIXTURE = "refrigerator_tp1x_ref_21k_autodoor" + + +def _fridge(): + resources = _load_device(FIXTURE) + info = resources["/information/vs/0"] + reg = for_device_by_model( + info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] + ) + return reg, resources + + +def _state(): + reg, resources = _fridge() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_refrigerator_registry(): + reg, _ = _fridge() + assert reg is not None and reg.name == "refrigerator" + + +def test_no_unbound_hrefs(): + reg, resources = _fridge() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_auto_door_voice_control_reads_status_lock(): + state = _state() + assert state["auto_door_opener"] is False + assert state["auto_door_voice_control"] is False + # This variant's dump has no ado.soundcontrol field -- gated absent. + assert "auto_door_sound_control" not in state + + +def test_auto_door_timer_reads_its_own_options(): + desc = next( + e + for e in fridge.AUTO_DOOR_TIMER.entities + if e.key == "auto_door_timer" and isinstance(e, SelectDesc) + ) + rep = { + "x.com.samsung.da.time.desired": "1", + "x.com.samsung.da.time.supportedOptions": ["1", "2", "3", "4", "5", "6"], + } + assert desc.value_fn(rep["x.com.samsung.da.time.desired"]) == "1" + assert desc.options_field == "x.com.samsung.da.time.supportedOptions" + assert desc.write_fn is not None + assert desc.write_fn("3", rep) == ( + ["autodoor", "timer", "vs", "0"], + {"x.com.samsung.da.time.desired": "3"}, + ) + + +def test_auto_door_single_variant_href_bound_with_no_entities(): + """/autodoor/single/vs/0 only ever declares openOptions=['Single'] -- + no paired current/desired field to expose, so it's coverage-only via + the shared AUTO_DOOR_VARIANT pattern cap, not an entry of its own.""" + assert fridge.AUTO_DOOR_VARIANT.entities == () + assert fridge.AUTO_DOOR_VARIANT.match_fn is not None + _reg, resources = _fridge() + assert fridge.AUTO_DOOR_VARIANT.match_fn(resources["/autodoor/single/vs/0"], resources) diff --git a/tests/test_refrigerator_tp1x_ref_21k_kimchi_capabilities.py b/tests/test_refrigerator_tp1x_ref_21k_kimchi_capabilities.py new file mode 100644 index 0000000..321a3e3 --- /dev/null +++ b/tests/test_refrigerator_tp1x_ref_21k_kimchi_capabilities.py @@ -0,0 +1,57 @@ +"""Kimchi-refrigerator Auto Door Open variant (issue #328, +oic.d.krefrigerator device type -- TP1X_REF_21K board, single kimchi +compartment). Same STATUS_LOCK/AUTO_DOOR_TIMER shape as the regular +TP1X_REF_21K, at the kimchi-specific /autodoor/kimchi/vs/0 declaration +href. +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve +from custom_components.localthings.registry.capabilities import fridge +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + +FIXTURE = "refrigerator_tp1x_ref_21k_kimchi" +DEVICE_TYPES = ("oic.wk.d", "oic.d.krefrigerator") + + +def _fridge(): + resources = _load_device(FIXTURE) + reg = resolve(resources, device_types=DEVICE_TYPES) + return reg, resources + + +def _state(): + reg, resources = _fridge() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_via_oic_type(): + reg = for_device_by_oic_type(DEVICE_TYPES) + assert reg is not None and reg.name == "refrigerator" + + +def test_resolves_to_refrigerator_registry(): + reg, _ = _fridge() + assert reg is not None and reg.name == "refrigerator" + + +def test_no_unbound_hrefs(): + reg, resources = _fridge() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_auto_door_opener_present(): + state = _state() + assert "auto_door_opener" in state + assert "auto_door_timer" in state + + +def test_auto_door_kimchi_variant_href_bound_with_no_entities(): + assert fridge.AUTO_DOOR_VARIANT.entities == () + assert fridge.AUTO_DOOR_VARIANT.match_fn is not None + _reg, resources = _fridge() + assert fridge.AUTO_DOOR_VARIANT.match_fn(resources["/autodoor/kimchi/vs/0"], resources) diff --git a/tests/test_refrigerator_winecellar_capabilities.py b/tests/test_refrigerator_winecellar_capabilities.py new file mode 100644 index 0000000..5567d4a --- /dev/null +++ b/tests/test_refrigerator_winecellar_capabilities.py @@ -0,0 +1,90 @@ +"""Wine-cellar refrigerator variant (issue #328, x.com.st.d.winecellar +device type -- RW33C99B1TFG-class, TP1X_REF_21K board). Auto Door Open's +winecellar-specific declaration href, a deodorizing filter reported at a +different href than the regular AIR_FILTER, a multi-compartment pantry +select, and a table-revision info resource -- all new coverage this dump +surfaced. +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve +from custom_components.localthings.registry.capabilities import fridge +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import SelectDesc +from tests.conftest import _load_device + +FIXTURE = "refrigerator_winecellar" +DEVICE_TYPES = ("oic.wk.d", "x.com.st.d.winecellar") + + +def _fridge(): + resources = _load_device(FIXTURE) + reg = resolve(resources, device_types=DEVICE_TYPES) + return reg, resources + + +def _state(): + reg, resources = _fridge() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_via_oic_type(): + reg = for_device_by_oic_type(DEVICE_TYPES) + assert reg is not None and reg.name == "refrigerator" + + +def test_no_unbound_hrefs(): + reg, resources = _fridge() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_auto_door_sound_control_present_here_only(): + """This variant's dump is the only one that carries + ado.soundcontrol -- the other TP1X_REF_21K auto-door fixtures don't.""" + state = _state() + assert state["auto_door_sound_control"] is True + assert state["auto_door_voice_control"] is False + + +def test_deodor_filter_has_its_own_keys_not_air_filters(): + """Same shape/reasoning as fridge.AIR_FILTER (percentage-not-raw-count, + see its own comment) but its own 'deodor_'-prefixed keys, not a shared + tuple -- AIR_FILTER's own docstring picked 'air_' specifically to avoid + colliding with WATER_FILTER's filter_usage/filter_status, and reusing + AIR_FILTER.entities verbatim here would silently recreate that same + collision if a unit ever reports both hrefs.""" + assert fridge.DEODOR_FILTER.entities != fridge.AIR_FILTER.entities + state = _state() + assert state["deodor_filter_status"] == "normal" + # The device's own sentinel, relayed as-is -- not a real 0-100 reading + # on this unit, and no second dump to say what else -1 could mean. + assert state["deodor_filter_usage"] == -1 + assert "air_filter_usage" not in state + assert "air_filter_status" not in state + + +def test_winecellar_pantry_zone_mode_options_and_write(): + desc = next( + e + for e in fridge.WINECELLAR_PANTRY_ZONE.entities + if e.key == "winecellar_pantry_zone_mode" and isinstance(e, SelectDesc) + ) + assert desc.options_field == "x.com.samsung.da.supportedOptions" + assert desc.write_fn is not None + assert desc.write_fn("Cheese", {}) == ( + ["status", "winecellar", "pantry", "one", "vs", "0"], + {"x.com.samsung.da.mode": "Cheese"}, + ) + state = _state() + assert state["winecellar_pantry_zone_mode"] == "Fruit" + + +def test_winecellar_variant_and_info_hrefs_bound_with_no_entities(): + assert fridge.AUTO_DOOR_VARIANT.entities == () + assert fridge.WINECELLAR_INFO.entities == () + assert fridge.AUTO_DOOR_VARIANT.match_fn is not None + _reg, resources = _fridge() + assert fridge.AUTO_DOOR_VARIANT.match_fn(resources["/autodoor/winecellar/vs/0"], resources)