Files
localthings/custom_components/localthings/registry/capabilities/airconditioner.py
T
Marc Billow 7086b134c0 Add code comment guidelines; dramatically trim excessive comments
CONTRIBUTING.md gains a "Code comments" section: comment the why not
the what, keep it to a sentence or two with a pointer to the load-bearing
evidence, don't re-derive a sibling's already-documented reasoning, and
move failed-attempt investigation logs out of inline comments.

Applied that policy across the codebase: condensed sprawling module
docstrings, per-entity essays, and multi-paragraph rationale blocks down
to their load-bearing conclusions, while preserving the actual "why"
(issue numbers, calibration evidence, gotchas, don't-guess rationale).
No functional code changed — verified via diff review, ruff, ty, and the
full pytest suite (1211 passed).

One inline investigation log (the AC filter-reset "tried and failed"
notes) moved to docs/investigations/ac-filter-reset.md rather than being
deleted, per the new guideline on where that kind of record belongs.
2026-08-05 01:24:17 +00:00

1070 lines
39 KiB
Python

"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class,
issue #17 / ARTIK051_PRAC_20K).
Core controls (power, mode, temperature, fan, swing, preset) surface as one
composite HA `climate` entity; climate.py reads the sibling resources bound
here off the coordinator snapshot. These caps stay out of the global
`ALL`/`CAPABILITIES`: several hrefs (`/mode/vs/0`, `/temperatures/vs/0`,
`/humidity/*`) collide with other families' schemas (see
capabilities/__init__.py) -- AC-only, by_type registry only.
"""
from dataclasses import replace
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
ClimateDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from . import common
from .common import filter_usage_percent, normalize_temp_unit
from .laundry import option_write
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _beep_on(rep):
"""Beep on/off from the `Volume_*` option token (`Volume_Mute` = off,
else on)."""
tok = _option_token(rep, "Volume")
if tok is None:
return None
return tok != "Mute"
def _beep_write(payload, rep, href=None):
"""Toggle beep via a single-token options write (a full options RMW
reverts on ARTIK051_PRAC). 'On' restores the last non-Mute level rather
than forcing Volume_100, so a user's intermediate setting survives an
off/on cycle; falls back to 100 when no prior level is known."""
if payload not in ("On", "Off"):
return None
if payload == "Off":
token = "Mute"
else:
prev = _option_token(rep, "Volume")
token = prev if (prev and prev != "Mute") else "100"
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Volume", token),
}
def _tropical_night_value(rep):
"""Tropical night mode level (0-16) from the `Sleep_<N>` option token."""
tok = _option_token(rep, "Sleep")
if tok is None:
return None
return _int(tok)
def _tropical_night_write(value, rep, href=None):
"""Set tropical night level via a single-token `Sleep_<N>` write.
Cloud counterpart: custom.airConditionerTropicalNightMode."""
try:
level = round(float(value))
except (TypeError, ValueError):
return None
if not 0 <= level <= 16:
return None
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Sleep", str(level)),
}
def _filter_unit(rep):
"""Filter-usage unit, normalized from filterCapacityUnit ('Hour' -> 'h')."""
u = rep.get("x.com.samsung.da.filterCapacityUnit")
return {"Hour": "h", "Minute": "min", "Second": "s"}.get(u, u or "h")
def _threshold_write(payload, rep, href=None):
"""filterDesiredUsage is locally writable via a plain scalar POST
(confirmed live on ARTIK051_PRAC). The Select only surfaces where the
device advertises supportedFilterDesiredUsage, so options are known
rather than guessed."""
return ["filter", "airdustfilter", "vs", "0"], {
"x.com.samsung.da.filterDesiredUsage": payload,
}
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element
array; only v[0] is used, since the second element's meaning is
unconfirmed. No device_class is set: the resource exposes no unit."""
for it in items or []:
if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_:
v = it.get("x.com.samsung.da.value")
if isinstance(v, list) and v:
return str(v[0])
return None
return None
def _has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this type.
This only proves the type is *listed*, not that the reading is real:
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
values on units the reporter confirmed don't have the hardware. So
entities gated on this stay disabled by default (see AIR_QUALITY) rather
than existence-gated further, to avoid silently dropping real readings
on hardware not yet seen.
"""
def fn(rep, resources):
return any(
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
for i in (rep.get("x.com.samsung.da.items") or [])
)
return fn
# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
# here so climate.py and the coverage list below can't drift out of sync.
HREF_MODE = "/mode/vs/0" # primary, bound by CLIMATE
HREF_POWER = "/power/0" # OCF on/off
HREF_POWER_VS = "/power/vs/0" # vendor fallback for on/off
HREF_TEMP_CURRENT = "/temperature/current/0"
HREF_TEMP_DESIRED = "/temperature/desired/0"
HREF_TEMP_CONTROL = "/temperature/control/vs/0" # target_temperature_step
HREF_WIND_STRENGTH = "/wind/strength/vs/0" # fan_mode
HREF_WIND_DIRECTION = "/wind/direction/vs/0" # swing_mode
# WindFree boards (issue #126) have no HREF_WIND_DIRECTION and instead carry a
# 2-axis oscillation resource; climate.py falls back to this when absent.
HREF_WIND_OSCILLATION = "/wind/oscillation/vs/0" # swing_mode fallback
HREF_CONVENIENT = "/mode/convenient/vs/0" # preset_mode
HREF_TEMPS_VS = "/temperatures/vs/0" # vendor temp fallback (items[] array)
# Legacy ARTIK051 boards (issue #136) have no /wind/* resources: fan speed and
# vane direction live together here instead. See climate.py's _legacy_airflow.
HREF_AIRFLOW = "/airflow/vs/0" # legacy fan_mode + swing_mode
CLIMATE_CONSUMED_HREFS = [
HREF_POWER,
HREF_POWER_VS,
HREF_TEMP_CURRENT,
HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL,
HREF_TEMPS_VS,
HREF_WIND_STRENGTH,
HREF_WIND_DIRECTION,
HREF_WIND_OSCILLATION,
HREF_CONVENIENT,
HREF_AIRFLOW,
]
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _temps_vs_item(rep):
"""First item of the vendor `/temperatures/vs/0` items[] array -- the
Tizen Lite board's only current-temperature source. Duplicated from
climate.py's identical helper to avoid a capabilities<->platform import
cycle."""
items = rep.get("x.com.samsung.da.items")
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
def _temps_vs_current(rep):
return _num(_temps_vs_item(rep).get("x.com.samsung.da.current"))
def _temps_vs_unit(rep):
return normalize_temp_unit(_temps_vs_item(rep).get("x.com.samsung.da.unit"), "°C")
def _first_mode(rep):
"""Representative scalar for the flattened golden state; the real
climate entity derives hvac_mode from power + mode instead."""
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _mode_options(rep):
opts = rep.get("x.com.samsung.da.options")
return opts if isinstance(opts, (list, tuple)) else ()
def _has_display_light_option(rep, resources):
"""True when the panel light lives in /mode/vs/0's `Light_*` option
token rather than a dedicated /light/vs/0 switch -- the two encodings
are mutually exclusive across observed boards."""
return any(isinstance(o, str) and o.startswith("Light_") for o in _mode_options(rep))
def _display_light_on(rep):
"""Panel light state from /mode/vs/0's options. The token is INVERTED:
a live toggle test showed `Light_Off` while lit and `Light_On` while
dark (the flag really means "night/display-off mode active")."""
for o in _mode_options(rep):
if isinstance(o, str) and o.startswith("Light_"):
return o == "Light_Off"
return None
def _display_light_write(payload, rep, href=None):
"""Toggle the panel light via a single-token options write. Polarity is
inverted (see _display_light_on): ON writes 'Light_Off', OFF writes
'Light_On'."""
token = "Off" if payload == "On" else "On"
return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Light", token)})
# Legacy ARTIK051 boards keep several settings that newer boards expose as
# their own resources (/option/*, /electriccurrent/vs/0, ...) as
# `<Prefix>_<value>` tokens in /mode/vs/0's options instead. Reads pull the
# token apart; writes reuse the same single-token merge as the display light.
def _option_token(rep, prefix):
"""Value part of a `<prefix>_<value>` token in /mode/vs/0's options."""
for option in _mode_options(rep):
if isinstance(option, str) and option.startswith(prefix + "_"):
return option.split("_", 1)[1]
return None
def is_legacy_board(resources):
"""True for the board generation whose airflow lives in /airflow/vs/0
rather than /wind/strength/vs/0 -- every AC dump on record has one shape
or the other. Same test as climate.py's _legacy_airflow(), so the
entities below and the climate entity can't disagree about generation."""
return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources
# Legacy ARTIK051 boards (issue #193, ARTIK051_KRAC_18K) report
# /energy/consumption/vs/0's cumulativePower in centiwatt-hours -- 100x the
# plain Wh every other board family (and common.wh_to_kwh) assumes. Confirmed
# against the reporter's own SmartThings-app reading: raw 117430000 vs the
# app's 1,174.30 kWh is exactly a /100000 factor.
def _legacy_cumulative_power_kwh(v):
try:
n = float(v)
except (TypeError, ValueError):
return None
return round(n / 100000.0, 2)
ENERGY_METER_LEGACY = replace(
common.ENERGY_METER,
match_fn=lambda rep, resources: is_legacy_board(resources),
entities=tuple(
replace(e, value_fn=_legacy_cumulative_power_kwh) if e.key == "energy_kwh" else e
for e in common.ENERGY_METER.entities
),
)
# Non-legacy counterpart, needed so both caps can share this href without
# tripping the "multiple caps need a discriminator" build check.
ENERGY_METER_GENERIC = replace(
common.ENERGY_METER,
match_fn=lambda rep, resources: not is_legacy_board(resources),
)
def _has_option_token(prefix):
return lambda rep, resources: (
is_legacy_board(resources) and _option_token(rep, prefix) is not None
)
def _option_token_on(prefix):
return lambda rep: _option_token(rep, prefix) == "On"
def _option_token_num(prefix, offset=0, divisor=1):
def read(rep):
raw = _option_token(rep, prefix)
try:
return (float(raw) - offset) / divisor
except (TypeError, ValueError):
return None
return read
def _option_switch_write(prefix):
def write(payload, rep, href=None):
return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write(prefix, payload)})
return write
def _option_number_write(prefix):
def write(payload, rep, href=None):
return (
["mode", "vs", "0"],
{"x.com.samsung.da.options": option_write(prefix, str(round(float(payload))))},
)
return write
def _odor_controller_active(rep):
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
option token (matches the SmartThings cloud's airConditionerOdorController
State field). Read-only: no confirmed write path."""
tok = _option_token(rep, "SmartCoolClean")
if tok is None:
return None
return tok == "On"
def _odor_controller_progress(rep):
"""0-100 progress of the odor-controller cycle, from the
`ProgressSmartClean_<N>` token."""
return _int(_option_token(rep, "ProgressSmartClean"))
def _humidity(rep):
"""Relative humidity, preferring the 5%-rounded field where present.
ARTIK051 boards have no fivepercentHumidity and report plain `humidity`
instead, which only populates for ~30s while "Air monitoring" is on
before zeroing itself -- so 0 there means "not measuring" and is reported
as unknown rather than 0%. fivepercentHumidity has no such quirk (issue
#160), so its own 0 readings pass through unchanged.
"""
if "x.com.samsung.da.fivepercentHumidity" in rep:
return _num(rep["x.com.samsung.da.fivepercentHumidity"])
if "x.com.samsung.da.humidity" in rep:
value = _num(rep["x.com.samsung.da.humidity"])
return value if value else None
return None
def _climate_write(payload, rep, href=None):
"""Maps a (kind, value) command from the climate platform to the
(path_segs, body) for that one sub-write; `value` is already the raw
device code. Power always goes to vendor `/power/vs/0` (OCF `/power/0`
is absent on most boards). Temperature channel (OCF vs vendor) is picked
by the platform. Mode/fan/swing/preset are always the vendor `/x/vs/0`
resources. Each write sends only its own field(s); the device merges the
rest itself (see common.merge_items_field / merge_options_field)."""
kind, value = payload
if kind == "power":
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
if kind == "mode":
return (["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]})
if kind == "temperature_ocf":
return (["temperature", "desired", "0"], {"temperature": round(float(value))})
if kind == "temperature":
# Vendor items[] array; only one item observed on every AC dump, id '0'.
return (
["temperatures", "vs", "0"],
{
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.desired": str(round(float(value))),
}
]
},
)
if kind == "fan":
return (["wind", "strength", "vs", "0"], {"x.com.samsung.da.modes": value})
if kind == "swing":
return (["wind", "direction", "vs", "0"], {"x.com.samsung.da.modes": value})
if kind == "oscillation":
# value is HA's swing_mode string; both axes are independent Swing|Fix
# toggles written together (see climate.py's oscillation fallback).
return (
["wind", "oscillation", "vs", "0"],
{
"vertical": "Swing" if value in ("vertical", "both") else "Fix",
"horizontal": "Swing" if value in ("horizontal", "both") else "Fix",
},
)
if kind == "fan_legacy":
return (["airflow", "vs", "0"], {"x.com.samsung.da.speedLevel": str(value)})
if kind == "swing_legacy":
return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value})
if kind == "preset_legacy":
return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)})
if kind == "preset":
return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value})
return None
CLIMATE = Capability(
href=HREF_MODE,
poll_tier="warm",
entities=(
ClimateDesc(
key="climate",
translation_key="airconditioner",
rep_fn=_first_mode,
write_fn=_climate_write,
),
# Panel light switch for boards that encode it in /mode/vs/0's options
# instead of a dedicated /light/vs/0 (see _has_display_light_option).
# Shares the switch.display_light translation key with DISPLAY_LIGHT
# below; mutually exclusive per href.
SwitchDesc(
key="display_light",
rep_fn=_display_light_on,
exists_fn=_has_display_light_option,
write_fn=_display_light_write,
icon="mdi:led-on",
entity_category="config",
),
# Beep on/off from the Volume_* token. Applies uniformly across board
# generations (issue #136: previously modeled as a graduated Number
# for legacy boards, but no unit ever reported an intermediate value,
# and the Number's write path couldn't produce the literal 'Mute'
# token needed to turn it off).
SwitchDesc(
key="beep",
rep_fn=_beep_on,
exists_fn=lambda rep, resources: _option_token(rep, "Volume") is not None,
write_fn=_beep_write,
icon="mdi:volume-high",
entity_category="config",
),
# Tropical night level (Sleep_<N> token), gated off the legacy board
# (its Sleep_ token is the good_sleep Number below instead). exists_fn
# only proves the token slot is present, not that the feature is real
# (issue #166 reports Sleep_0 on a unit confirmed to have no such
# mode) -- disabled by default so units that do have it can enable it.
NumberDesc(
key="tropical_night_mode",
rep_fn=_tropical_night_value,
exists_fn=lambda rep, resources: (
not is_legacy_board(resources) and _option_token(rep, "Sleep") is not None
),
write_fn=_tropical_night_write,
native_min=0,
native_max=16,
step=1,
enabled_default=False,
icon="mdi:weather-night",
entity_category="config",
),
# Settings that this board generation keeps as options[] tokens.
SwitchDesc(
key="spi",
rep_fn=_option_token_on("Spi"),
exists_fn=_has_option_token("Spi"),
write_fn=_option_switch_write("Spi"),
icon="mdi:air-purifier",
entity_category="config",
),
# Shares AUTO_CLEAN's catalog entry (same feature, different board
# generation) under a distinct key.
SwitchDesc(
key="auto_clean_legacy",
translation_key="auto_clean",
rep_fn=_option_token_on("Autoclean"),
exists_fn=_has_option_token("Autoclean"),
write_fn=_option_switch_write("Autoclean"),
icon="mdi:fan-auto",
entity_category="config",
),
SwitchDesc(
key="air_monitoring",
rep_fn=_option_token_on("AirMonitoring"),
exists_fn=_has_option_token("AirMonitoring"),
write_fn=_option_switch_write("AirMonitoring"),
icon="mdi:air-filter",
entity_category="config",
),
# "Good Sleep" timer. 0 = off; the upper bound is a guess (only 0 has
# been observed on hardware), so a write above 0 is unverified.
NumberDesc(
key="good_sleep",
rep_fn=_option_token_num("Sleep"),
exists_fn=_has_option_token("Sleep"),
write_fn=_option_number_write("Sleep"),
native_min=0,
native_max=12,
step=1,
unit="h",
icon="mdi:sleep",
entity_category="config",
),
# Outdoor temperature, offset by 55 -- calibrated against an
# independent thermometer (token 75 while it read 20.3°C).
SensorDesc(
key="outdoor_temperature",
rep_fn=_option_token_num("OutdoorTemp", offset=55),
exists_fn=_has_option_token("OutdoorTemp"),
device_class="temperature",
state_class="measurement",
unit="°C",
icon="mdi:home-thermometer-outline",
),
# Filter time in tenths of an hour, counting UP since last filter
# reset; scale and direction confirmed against the Samsung app and
# the /alarms/vs/0 threshold crossing (500h). No reset entity: no
# local write path has been found -- see
# docs/investigations/ac-filter-reset.md for what's been tried.
SensorDesc(
key="filter_time",
rep_fn=_option_token_num("FilterTime", divisor=10),
exists_fn=_has_option_token("FilterTime"),
device_class="duration",
unit="h",
state_class="measurement",
icon="mdi:air-filter",
),
# FilterTime_'s threshold, exposed as a static 4-way radio
# (180/300/500/700h, matching the app) since options[] tokens carry
# no supported-values list to read from, unlike air_filter_threshold
# on newer boards.
SelectDesc(
key="filter_alarm_time",
rep_fn=lambda rep: _option_token(rep, "FilterAlarmTime"),
exists_fn=_has_option_token("FilterAlarmTime"),
options=("180", "300", "500", "700"),
write_fn=_option_switch_write("FilterAlarmTime"),
icon="mdi:alarm",
entity_category="config",
),
# Odor-controller ("Smart Cool Clean") state + progress -- see
# _odor_controller_active's docstring.
BinarySensorDesc(
key="odor_controller_active",
rep_fn=_odor_controller_active,
exists_fn=lambda rep, resources: _option_token(rep, "SmartCoolClean") is not None,
icon="mdi:air-filter",
entity_category="diagnostic",
),
SensorDesc(
key="odor_controller_progress",
rep_fn=_odor_controller_progress,
exists_fn=lambda rep, resources: _option_token(rep, "ProgressSmartClean") is not None,
unit="%",
state_class="measurement",
icon="mdi:progress-check",
entity_category="diagnostic",
),
),
)
AIR_PURIFY = Capability(
href="/option/airpurify/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="air_purify",
field="x.com.samsung.da.modes",
icon="mdi:air-purifier",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "airpurify", "vs", "0"],
{"x.com.samsung.da.modes": "On" if p == "On" else "Off"},
),
),
),
)
AUTO_CLEAN = Capability(
href="/option/autoclean/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="auto_clean",
field="x.com.samsung.da.settingStatus",
icon="mdi:spray-bottle",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "autoclean", "vs", "0"],
{"x.com.samsung.da.settingStatus": "On" if p == "On" else "Off"},
),
),
# Run state (vs settingStatus's "feature enabled"): status is
# Start/Stop per the resource's own supportedStatus.
BinarySensorDesc(
key="auto_clean_running",
field="x.com.samsung.da.status",
icon="mdi:spray-bottle",
entity_category="diagnostic",
value_fn=lambda v: v == "Start",
),
# Percent through the cycle; matches the appliance's own display.
SensorDesc(
key="auto_clean_progress",
field="x.com.samsung.da.progress",
icon="mdi:progress-clock",
unit="%",
state_class="measurement",
entity_category="diagnostic",
value_fn=common.int_or_none,
),
),
)
AIR_FILTER = Capability(
href="/filter/airdustfilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="air_filter_usage",
rep_fn=filter_usage_percent,
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
),
# Lifetime hour counter, resets only on filter replacement.
SensorDesc(
key="air_filter_usage_hours",
field="x.com.samsung.da.filterUsage",
device_class="duration",
state_class="total_increasing",
unit_fn=_filter_unit,
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=_int,
),
# Locally writable alarm threshold (see _threshold_write); only
# surfaces where supportedFilterDesiredUsage is advertised.
SelectDesc(
key="air_filter_threshold",
field="x.com.samsung.da.filterDesiredUsage",
options_field="x.com.samsung.da.supportedFilterDesiredUsage",
exists_fn=lambda rep, res: bool(
rep.get("x.com.samsung.da.supportedFilterDesiredUsage")
),
icon="mdi:alarm",
entity_category="config",
write_fn=_threshold_write,
value_fn=lambda v: str(v) if v is not None else None,
),
SensorDesc(
key="air_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,
),
),
)
def _pm1_threshold_write(payload, rep, href=None):
"""Same contract as _threshold_write, against this filter's own href --
not yet confirmed live, so the Select this backs stays gated behind
supportedFilterDesiredUsage's presence, same as AIR_FILTER's."""
return ["filter", "airdustPM1filter", "vs", "0"], {
"x.com.samsung.da.filterDesiredUsage": payload,
}
def _has_filter_field(field):
return lambda rep, resources: rep.get(field) is not None
# Second, PM1-rated filter some TP1X_FAC boards report alongside AIR_FILTER's
# href (issue #270). Some units report only the capacity/unit fields with no
# live data at all, so every entity here is individually gated on its own
# field's presence.
AIR_FILTER_PM1 = Capability(
href="/filter/airdustPM1filter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="air_filter_pm1_usage",
rep_fn=filter_usage_percent,
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"),
),
SensorDesc(
key="air_filter_pm1_usage_hours",
field="x.com.samsung.da.filterUsage",
device_class="duration",
state_class="total_increasing",
unit_fn=_filter_unit,
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=_int,
exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"),
),
SelectDesc(
key="air_filter_pm1_threshold",
field="x.com.samsung.da.filterDesiredUsage",
options_field="x.com.samsung.da.supportedFilterDesiredUsage",
exists_fn=lambda rep, res: bool(
rep.get("x.com.samsung.da.supportedFilterDesiredUsage")
),
icon="mdi:alarm",
entity_category="config",
write_fn=_pm1_threshold_write,
value_fn=lambda v: str(v) if v is not None else None,
),
SensorDesc(
key="air_filter_pm1_status",
field="x.com.samsung.da.filterStatus",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
exists_fn=_has_filter_field("x.com.samsung.da.filterStatus"),
),
),
)
DISPLAY_LIGHT = Capability(
href="/light/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="display_light",
field="mode",
icon="mdi:led-on",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["light", "vs", "0"],
{"mode": "On" if p == "On" else "Off"},
),
),
),
)
# UV-C sterilization LED (issue #270, TP1X_FAC_TIME_23K).
UV_LED = Capability(
href="/uvled/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="uv_led",
field="x.com.samsung.da.modes",
icon="mdi:lightbulb-fluorescent-tube",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["uvled", "vs", "0"],
{"x.com.samsung.da.modes": "On" if p == "On" else "Off"},
),
),
),
)
# Ventilation-reminder alarm toggle (issue #270). No supportedModes list to
# confirm the value set against, unlike UV_LED above -- not round-trip
# confirmed on real hardware.
VENTILATION_ALARM = Capability(
href="/ventilation/setting/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="ventilation_alarm",
field="alarm",
icon="mdi:bell-alert",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["ventilation", "setting", "vs", "0"],
{"alarm": "On" if p == "On" else "Off"},
),
),
),
)
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000).
MUTE_ONCE = Capability(
href="/option/muteonce/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="mute_once",
field="muteonce",
icon="mdi:volume-mute",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "muteonce", "vs", "0"],
{"muteonce": "On" if p == "On" else "Off"},
),
),
),
)
# Circuit-breaker current-limit setting (issue #38, TP1X board). No unit/label
# in the dump to confirm what the levels mean -- exposed read-only per the
# 'don't guess' rule rather than risking an unverified write to live hardware.
CURRENT_LIMIT = Capability(
href="/electriccurrent/vs/0",
poll_tier="cold",
entities=(
BinarySensorDesc(
key="current_limit_enabled",
field="operation",
icon="mdi:current-ac",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
SensorDesc(
key="current_limit_level",
field="modes",
icon="mdi:current-ac",
entity_category="diagnostic",
),
),
)
# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree). No
# confirmation of the behavioral difference between modes -- read-only, same
# precedent as CURRENT_LIMIT above.
ANOMALY_LOAD = Capability(
href="/anomalyload/vs/0",
poll_tier="cold",
entities=(
BinarySensorDesc(
key="overload_protection_active",
field="operation",
icon="mdi:flash-alert",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
SensorDesc(
key="overload_protection_mode",
field="mode",
device_class="enum",
options=("alarm", "powersaving"),
translation_key="overload_protection_mode",
icon="mdi:flash-alert",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
),
)
# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001). `status`
# is a bare On/Off with the same shape already shipped writable elsewhere in
# this file, so it's a switch despite no live-confirmed write. `mode` stays
# read-only: no dump evidence for what writing it does to a running
# compressor.
ABSENCE_POWER_SAVING = Capability(
href="/mds/absencepowersaving/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="absence_power_saving_active",
field="status",
icon="mdi:human-greeting-proximity",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["mds", "absencepowersaving", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="absence_power_saving_mode",
field="switchPowerSaveMode",
device_class="enum",
options=("eco", "normal", "comfort"),
translation_key="absence_power_saving_mode",
icon="mdi:leaf",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
),
)
# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING on
# the same dump; same shape and reasoning.
MOTION_DETECT_WIND = Capability(
href="/option/motiondetectwind/stateful/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="motion_detect_wind_active",
field="status",
icon="mdi:motion-sensor",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "motiondetectwind", "stateful", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="motion_detect_wind_mode",
field="modes",
device_class="enum",
options=("direct", "indirect"),
translation_key="motion_detect_wind_mode",
icon="mdi:weather-windy",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
),
)
# Standalone temperature sensor for history/automations (issue #75); the
# climate card only exposes current_temperature as an attribute. Shares key
# 'current_temperature_c' with the _VS variant below so only one ever binds.
CURRENT_TEMPERATURE = Capability(
href=HREF_TEMP_CURRENT,
poll_tier="warm",
entities=(
SensorDesc(
key="current_temperature_c",
field="temperature",
device_class="temperature",
state_class="measurement",
unit_fn=lambda rep: normalize_temp_unit(rep.get("units"), "°C"),
),
),
)
CURRENT_TEMPERATURE_VS = Capability(
href=HREF_TEMPS_VS,
poll_tier="warm",
match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources,
entities=(
SensorDesc(
key="current_temperature_c",
rep_fn=_temps_vs_current,
device_class="temperature",
state_class="measurement",
unit_fn=_temps_vs_unit,
),
),
)
# fivepercentHumidity is the only live reading on most dumps; the OCF
# /humidity/0 resource and this vendor resource's own `humidity` field both
# read a stuck 0 where fivepercentHumidity is absent. See _humidity's
# docstring for the ARTIK051 fallback and its zero-as-"not measuring" quirk.
HUMIDITY = Capability(
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="humidity",
rep_fn=_humidity,
device_class="humidity",
state_class="measurement",
unit="%",
),
),
)
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
# so it's a measurement; the others stay string diagnostics (see
# _sensor_item_value). All disabled by default: _has_sensor_type only proves
# the item type is listed, not that the sensor is real (see its docstring).
AIR_QUALITY = Capability(
href="/sensors/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="clean_level",
field="x.com.samsung.da.items",
icon="mdi:broom",
entity_category="diagnostic",
state_class="measurement",
exists_fn=_has_sensor_type("CleanLevel"),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
),
*tuple(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
entity_category="diagnostic",
exists_fn=_has_sensor_type(type_),
enabled_default=False,
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
)
for key, icon, type_ in (
("odor", "mdi:weather-windy", "Odor"),
("dust", "mdi:cloud", "Dust"),
("fine_dust", "mdi:cloud-outline", "FineDust"),
("super_fine_dust", "mdi:weather-fog", "SuperFineDust"),
)
),
),
)
# AC-scoped coverage: CLIMATE_CONSUMED_HREFS (read by the climate entity)
# plus vendor-duplicate / ambiguous / plumbing resources. These stay out of
# the global ignored.IGNORED because several collide with other families'
# schemas. A no-entity Capability still marks the href bound so discover()
# reports no gap. CLIMATE_CONSUMED_HREFS are pinned to 'warm' (rather than the
# Capability default of 'cold') so their state changes push instead of
# waiting on the ~30s full-summary sweep (issue #17).
_AC_IGNORED = [
"/humidity/0", # OCF mirror, stuck at 0 on every dump seen
"/personality/presence/vs/0", # presence-personalization plumbing (empty)
"/airflow/0", # OCF mirror of /airflow/vs/0; vendor form is the one used
# TP1X/TP2X-class housekeeping / opaque blobs with no user-actionable
# state or documented write contract. /option/muteonce/vs/0 and
# /selfcheck/vs/0 are deliberately NOT here -- see MUTE_ONCE above and
# common.SELF_CHECK, both of which have a confirmed, modelable contract.
"/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing
"/aisleep/vs/0", # AI-sleep feedback state (no actionable control)
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/keepnormalstate/vs/0", # internal keep-normal flag
"/mds/absencemonitoring/vs/0", # motion-detection sensor plumbing (empty)
"/mds/absencestate/vs/0", # motion-detection state (empty here)
"/remotedatacontrol/vs/0", # remote data-control session status
"/remotedeviceinfo/vs/0", # remote paired-device id list (empty here)
"/remotetemperature/vs/0", # external temp-sensor feed (unset here)
# Manual airflow-step position; overlaps the swing control already on the
# climate card, and the numeric-step meaning isn't documented.
"/stepcontrol/vs/0",
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/welcome/temperature/vs/0", # welcome-cooling plumbing
# System-AC-only (multi-indoor-subdevice commercial installs, issue #52):
# opaque hex-encoded installation topology, not user-actionable state.
"/sac/installationinfo/vs/0",
# Wind-Free 2-in-1 systems (issues #150/#153): paired-subdevice id list.
# registry/subdevices.py reads this same field to reach the second
# indoor subdevice when it's populated -- see that module's Pattern B.
"/subdevices/vs/0",
"/runn/vs/0", # undocumented single int (runningMode: always 0 seen)
# 2-in-1/multi-indoor-subdevice systems (issue #177): confirmed read-only
# subdevice count. Fetched separately by
# registry.subdevices.enumerate_subdevices, hence the entry here rather
# than a coverage gap.
"/multidevice/vs/0",
]
# Built as bare no-entity caps; folded into the AC registry (not global).
# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded -- CURRENT_TEMPERATURE /
# CURRENT_TEMPERATURE_VS above already cover those with real entities.
COVERAGE = [
Capability(href=h, poll_tier="warm")
for h in CLIMATE_CONSUMED_HREFS
if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS)
] + [Capability(href=h) for h in _AC_IGNORED]