Merge remote-tracking branch 'origin/main' into claude/issue-triage-138-latest-y90g78
# Conflicts: # tests/test_airconditioner_capabilities.py
This commit is contained in:
@@ -22,6 +22,7 @@ REGISTRY = DeviceRegistry(
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.AIR_QUALITY,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
|
||||
@@ -16,11 +16,123 @@ by_type registry.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc,
|
||||
BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
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,
|
||||
Volume_100 (and any non-Mute) = on. None when no Volume_ slot.
|
||||
|
||||
_option_token (defined further below, alongside the legacy-board token
|
||||
helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the
|
||||
full 'Volume_100' token -- shared with _option_token_num/_option_token_on,
|
||||
which this module's other options[] readers already rely 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 /mode/vs/0 options write (option_write's
|
||||
one-token merge -- 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 (e.g. Volume_50 set via the cloud) 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.
|
||||
|
||||
_option_token returns the token's value half already (e.g. '16' for
|
||||
'Sleep_16'), same convention as _beep_on above.
|
||||
"""
|
||||
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>` options write.
|
||||
Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
|
||||
try:
|
||||
level = int(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):
|
||||
"""Unit of the filter-usage fields, normalised from filterCapacityUnit
|
||||
('Hour' -> 'h'). Wired through unit_fn so a board advertising a different
|
||||
unit doesn't silently mislabel a duration statistic."""
|
||||
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: a plain scalar POST of the
|
||||
field to /filter/airdustfilter/vs/0 is 2.04-accepted and persists
|
||||
(confirmed live on ARTIK051_PRAC: POST 700 -> 2.04, read-back 700). The
|
||||
Select only surfaces where the device advertises
|
||||
supportedFilterDesiredUsage, so the valid options are known rather than
|
||||
guessed; boards without that enum leave this writable field unexposed."""
|
||||
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. The resource exposes no unit, so no device_class is
|
||||
set until a populated reading + unit is observed (the 'don't guess' rule).
|
||||
|
||||
Dust/FineDust/SuperFineDust report a 2-element array (['0','0']) while
|
||||
CleanLevel/Odor report a single element -- the second element's meaning is
|
||||
unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as
|
||||
a string rather than coerced numeric because only CleanLevel has
|
||||
corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar)."""
|
||||
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_):
|
||||
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. The climate entity (climate.py) binds the
|
||||
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
|
||||
@@ -313,6 +425,29 @@ CLIMATE = Capability(
|
||||
exists_fn=_has_display_light_option,
|
||||
write_fn=_display_light_write,
|
||||
icon='mdi:led-on', entity_category='config'),
|
||||
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
|
||||
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
|
||||
# Gated off the legacy ARTIK051 board generation (see is_legacy_board):
|
||||
# that generation's own Volume_ token is already modeled as the
|
||||
# buzzer_volume Number below, and both reading the same options[] slot
|
||||
# into two entities would be redundant.
|
||||
SwitchDesc(key='beep', rep_fn=_beep_on,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not is_legacy_board(resources)
|
||||
and _option_token(rep, 'Volume') is not None),
|
||||
write_fn=_beep_write,
|
||||
icon='mdi:volume-high', entity_category='config'),
|
||||
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
|
||||
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
|
||||
# Gated off the legacy board for the same reason as beep above -- its
|
||||
# Sleep_ token is already the good_sleep Number below.
|
||||
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not is_legacy_board(resources)
|
||||
and _option_token(rep, 'Sleep') is not None),
|
||||
write_fn=_tropical_night_write,
|
||||
native_min=0, native_max=16, step=1,
|
||||
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'),
|
||||
@@ -403,6 +538,28 @@ AIR_FILTER = Capability(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=filter_usage_percent,
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
# filterUsage is a lifetime hour counter that only resets on filter
|
||||
# replacement -- total_increasing so HA's long-term statistics handle
|
||||
# the reset rather than treating it as a bounded measurement.
|
||||
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),
|
||||
# The alarm threshold (filterDesiredUsage) is a locally writable option:
|
||||
# see _threshold_write. Surfaces as a Select only where the device
|
||||
# advertises supportedFilterDesiredUsage; boards without that enum
|
||||
# leave it unexposed rather than guess the valid set.
|
||||
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'),
|
||||
@@ -538,6 +695,38 @@ HUMIDITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# /sensors/vs/0 items[] carry live air-quality readings. Removed from
|
||||
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href. CleanLevel is
|
||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar
|
||||
# (tp1x_da_ac_rac_01011 reports both as '1'), so it's a measurement; the others
|
||||
# are 1- or 2-element arrays with no corroborating scalar, so they stay string
|
||||
# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only
|
||||
# v[0] is taken). No unit is advertised on the resource, so no device_class.
|
||||
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'),
|
||||
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_),
|
||||
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: the CLIMATE_CONSUMED_HREFS above (read by the climate
|
||||
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
|
||||
@@ -557,9 +746,6 @@ HUMIDITY = Capability(
|
||||
# of waiting on the summary sweep.
|
||||
# ---------------------------------------------------------------------------
|
||||
_AC_IGNORED = [
|
||||
# All-zero and ambiguously encoded on this model (2-value arrays); the
|
||||
# 'don't guess' rule -- leave unmodeled rather than invent entities.
|
||||
'/sensors/vs/0',
|
||||
# Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor
|
||||
# resource's usable fivepercentHumidity field instead; this OCF-standard
|
||||
# one has no corresponding live value confirmed yet.
|
||||
|
||||
@@ -187,12 +187,18 @@
|
||||
},
|
||||
"target_humidity": {
|
||||
"name": "Target humidity"
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropical night mode"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ai_energy_level": {
|
||||
"name": "AI Energy Mode level"
|
||||
},
|
||||
"air_filter_threshold": {
|
||||
"name": "Filter alarm threshold"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Beverage zone mode",
|
||||
"state": {
|
||||
@@ -580,6 +586,9 @@
|
||||
"air_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filter usage hours"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Air sensing state"
|
||||
},
|
||||
@@ -750,9 +759,6 @@
|
||||
"hood_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "Humidity"
|
||||
},
|
||||
"instance_temperature": {
|
||||
"name": "{instance_name} temperature"
|
||||
},
|
||||
@@ -892,6 +898,9 @@
|
||||
"auto_clean": {
|
||||
"name": "Auto clean"
|
||||
},
|
||||
"beep": {
|
||||
"name": "Beep"
|
||||
},
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
|
||||
@@ -187,12 +187,18 @@
|
||||
},
|
||||
"target_humidity": {
|
||||
"name": "Doelvochtigheid"
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropische nachtmodus"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ai_energy_level": {
|
||||
"name": "Niveau AI Energy Mode"
|
||||
},
|
||||
"air_filter_threshold": {
|
||||
"name": "Filteralarmdrempel"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modus drankenzone",
|
||||
"state": {
|
||||
@@ -580,6 +586,9 @@
|
||||
"air_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filterverbruik (uren)"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Status luchtmeting"
|
||||
},
|
||||
@@ -750,9 +759,6 @@
|
||||
"hood_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"humidity": {
|
||||
"name": "Luchtvochtigheid"
|
||||
},
|
||||
"instance_temperature": {
|
||||
"name": "Temperatuur {instance_name}"
|
||||
},
|
||||
@@ -892,6 +898,9 @@
|
||||
"auto_clean": {
|
||||
"name": "Automatisch reinigen"
|
||||
},
|
||||
"beep": {
|
||||
"name": "Piep"
|
||||
},
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
|
||||
+9
-1
@@ -2,16 +2,24 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"humidity",
|
||||
"power_watts"
|
||||
"odor",
|
||||
"power_watts",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+5
-2
@@ -2,8 +2,10 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
@@ -12,6 +14,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -2,8 +2,10 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
@@ -13,6 +15,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
@@ -11,6 +14,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"power_energy_kwh",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
"selfcheck_status",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_limit_enabled",
|
||||
"current_limit_level",
|
||||
@@ -16,6 +19,7 @@
|
||||
"mute_once",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
"selfcheck_status",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
@@ -12,6 +14,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
@@ -12,6 +14,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+4
-1
@@ -2,8 +2,10 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
@@ -11,6 +13,7 @@
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+9
-1
@@ -2,15 +2,23 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"clean_level",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"fine_dust",
|
||||
"humidity",
|
||||
"power_watts"
|
||||
"odor",
|
||||
"power_watts",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
@@ -16,6 +19,7 @@
|
||||
"overload_protection_mode",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
"selfcheck_status",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
|
||||
+2
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"energy_kwh",
|
||||
|
||||
@@ -9,7 +9,7 @@ from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import airconditioner
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -131,11 +131,12 @@ def test_climate_consumed_hrefs_declared_as_coverage():
|
||||
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
|
||||
standalone entities. /temperature/current/0 and /temperatures/vs/0 are
|
||||
NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give
|
||||
those two real sensor entities (issue #75)."""
|
||||
those two real sensor entities (issue #75). /sensors/vs/0 is also NOT
|
||||
here -- AIR_QUALITY gives it real entity sensors."""
|
||||
reg, _ = _ac()
|
||||
for href in ('/power/0', '/power/vs/0', '/temperature/desired/0',
|
||||
'/wind/strength/vs/0', '/mode/convenient/vs/0',
|
||||
'/sensors/vs/0', '/humidity/0'):
|
||||
'/humidity/0'):
|
||||
caps = reg.capabilities.get(href)
|
||||
assert caps, href
|
||||
assert all(c.entities == () for c in caps), href
|
||||
@@ -470,3 +471,245 @@ def test_fac_bora_climate_entity_present():
|
||||
def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed():
|
||||
assert '/subdevices/vs/0' in airconditioner._AC_IGNORED
|
||||
assert '/runn/vs/0' in airconditioner._AC_IGNORED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additive entities layered on the ARTIK051_PRAC family on top of the upstream
|
||||
# registry: beep (Volume_* option), tropical night mode (Sleep_<N> option),
|
||||
# filter usage hours + alarm threshold (filterUsage / filterDesiredUsage),
|
||||
# air-quality sensors (/sensors/vs/0 items), and software/firmware version
|
||||
# (/information/vs/0 items). Beep and tropical night use the single-token
|
||||
# option_write merge -- a full options RMW reverts on ARTIK051_PRAC (see the
|
||||
# [[samsung-ac-local-vs-cloud-control]] memory).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _beep_desc():
|
||||
return next(e for e in airconditioner.CLIMATE.entities if e.key == 'beep')
|
||||
|
||||
|
||||
def _tropical_desc():
|
||||
return next(e for e in airconditioner.CLIMATE.entities
|
||||
if e.key == 'tropical_night_mode')
|
||||
|
||||
|
||||
def test_beep_read_from_volume_token():
|
||||
"""Volume_100 (and any non-Mute) -> on; Volume_Mute -> off; no Volume_ slot
|
||||
-> None (entity won't bind via exists_fn)."""
|
||||
assert airconditioner._beep_on(
|
||||
{'x.com.samsung.da.options': ['Volume_100']}) is True
|
||||
assert airconditioner._beep_on(
|
||||
{'x.com.samsung.da.options': ['Volume_Mute']}) is False
|
||||
assert airconditioner._beep_on(
|
||||
{'x.com.samsung.da.options': ['Light_Off']}) is None
|
||||
assert airconditioner._beep_on({}) is None
|
||||
|
||||
|
||||
def test_beep_write_is_single_token_options_merge():
|
||||
"""One-element options array, not a full RMW (which reverts on
|
||||
ARTIK051_PRAC). 'On' restores the last non-Mute level so an intermediate
|
||||
setting (e.g. Volume_50) survives an off/on cycle; falls back to 100 when
|
||||
no prior level is known or the prior token is itself Mute."""
|
||||
write = _beep_desc().write_fn
|
||||
assert write('On', {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']})
|
||||
assert write('On', {'x.com.samsung.da.options': ['Volume_50']}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_50']})
|
||||
assert write('On', {'x.com.samsung.da.options': ['Volume_Mute']}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']})
|
||||
assert write('Off', {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_Mute']})
|
||||
assert write('Bogus', {}) is None
|
||||
|
||||
|
||||
def test_beep_absent_when_no_volume_token():
|
||||
"""TP1X_DA-AC-RAC-01011 carries no Volume_ option -- beep must not bind."""
|
||||
reg, resources = _ac_tp1x()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert 'beep' not in state
|
||||
|
||||
|
||||
def test_beep_state_on_windfree():
|
||||
"""The WindFree fixture reports Volume_100 -> beep reads True."""
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['beep'] is True
|
||||
|
||||
|
||||
def test_tropical_night_read_from_sleep_token():
|
||||
"""Sleep_<N> -> N; absent -> None."""
|
||||
assert airconditioner._tropical_night_value(
|
||||
{'x.com.samsung.da.options': ['Sleep_0']}) == 0
|
||||
assert airconditioner._tropical_night_value(
|
||||
{'x.com.samsung.da.options': ['Sleep_16']}) == 16
|
||||
assert airconditioner._tropical_night_value(
|
||||
{'x.com.samsung.da.options': ['Volume_100']}) is None
|
||||
assert airconditioner._tropical_night_value({}) is None
|
||||
|
||||
|
||||
def test_tropical_night_write_is_single_token_options_merge():
|
||||
"""Valid 0-16 -> `['Sleep_<N>']`; out of range / non-numeric -> None (no
|
||||
write). Cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
|
||||
write = _tropical_desc().write_fn
|
||||
assert write(0, {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_0']})
|
||||
assert write(16, {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_16']})
|
||||
assert write(17, {}) is None
|
||||
assert write(-1, {}) is None
|
||||
assert write('not-a-number', {}) is None
|
||||
# Float rounds to nearest int within range.
|
||||
assert write(5.6, {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_6']})
|
||||
|
||||
|
||||
def test_tropical_night_absent_when_no_sleep_token():
|
||||
"""TP1X_DA-AC-WAC (window AC) carries no Sleep_ option -- tropical night
|
||||
mode must not bind."""
|
||||
resources = _load_device('airconditioner_window_ac')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert 'tropical_night_mode' not in state
|
||||
|
||||
|
||||
def test_tropical_night_state_levels_across_fixtures():
|
||||
"""Sleep_0 / Sleep_6 / Sleep_16 surface as 0 / 6 / 16 respectively."""
|
||||
def level(name):
|
||||
res = _load_device(name)
|
||||
info = res['/information/vs/0']
|
||||
r = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
return flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get(
|
||||
'tropical_night_mode')
|
||||
assert level('airconditioner_windfree') == 0
|
||||
assert level('airconditioner_tp1x_da_ac_rac_01011') == 6
|
||||
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
|
||||
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')
|
||||
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.device_class == 'duration'
|
||||
assert desc.state_class == 'total_increasing'
|
||||
assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Hour'}) == 'h'
|
||||
assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Minute'}) == 'min'
|
||||
assert desc.unit_fn({}) == 'h' # static fallback when the field is absent
|
||||
|
||||
|
||||
def test_air_filter_threshold_is_writable_select():
|
||||
"""filterDesiredUsage is a locally writable option (confirmed live on
|
||||
ARTIK051_PRAC: POST 700 -> 2.04, persisted). Exposed as a Select keyed to
|
||||
the device's supportedFilterDesiredUsage enum; the write POSTs the scalar
|
||||
field back to /filter/airdustfilter/vs/0. Only binds where the enum is
|
||||
advertised -- boards without it leave this writable field unexposed rather
|
||||
than guess the valid set."""
|
||||
desc = next(e for e in airconditioner.AIR_FILTER.entities
|
||||
if e.key == 'air_filter_threshold')
|
||||
assert isinstance(desc, SelectDesc)
|
||||
assert desc.options_field == 'x.com.samsung.da.supportedFilterDesiredUsage'
|
||||
assert desc.exists_fn(
|
||||
{'x.com.samsung.da.supportedFilterDesiredUsage': ['180', '300', '500', '700']},
|
||||
{}) is True
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
# Current value is stringified for option matching.
|
||||
assert desc.value_fn('500') == '500'
|
||||
assert desc.value_fn(500) == '500'
|
||||
assert desc.value_fn(None) is None
|
||||
# Write POSTs the selected option as the scalar field.
|
||||
assert desc.write_fn('700', {}) == (
|
||||
['filter', 'airdustfilter', 'vs', '0'],
|
||||
{'x.com.samsung.da.filterDesiredUsage': '700'})
|
||||
|
||||
|
||||
def test_air_filter_threshold_absent_without_supported_enum():
|
||||
"""WindFree (ARTIK051_PRAC) advertises no supportedFilterDesiredUsage, so
|
||||
the writable threshold Select must not bind there -- even though the
|
||||
scalar field is present and writable. Don't expose a control whose valid
|
||||
options aren't known."""
|
||||
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%
|
||||
|
||||
|
||||
def test_air_filter_threshold_binds_on_enum_board():
|
||||
"""tp1x_rac advertises supportedFilterDesiredUsage -> threshold Select
|
||||
binds, current value read from filterDesiredUsage."""
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac')
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['air_filter_threshold'] == '500'
|
||||
|
||||
|
||||
def test_air_quality_sensors_from_sensors_vs_items():
|
||||
"""/sensors/vs/0 items[] surface as diagnostic scalars (no unit advertised
|
||||
on the resource, so no device_class until a populated reading + unit is
|
||||
observed -- the 'don't guess' rule). CleanLevel is corroborated as numeric
|
||||
by a top-level cleanLevel scalar, so it's an int measurement; the others
|
||||
are string diagnostics. Dust/FineDust/SuperFineDust carry a 2-element
|
||||
array whose second element is unconfirmed -- v[0] is taken as the reading
|
||||
(see _sensor_item_value)."""
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['clean_level'] == 0 # numeric (int), corroborated
|
||||
for key in ('odor', 'dust', 'fine_dust', 'super_fine_dust'):
|
||||
assert state[key] == '0' # string diagnostic
|
||||
# tp1x_da_ac_rac_01011 is the only fixture with a non-zero air-quality
|
||||
# reading -- the one that catches a value_fn regression.
|
||||
reg2, resources2 = _ac_tp1x()
|
||||
state2 = flatten(
|
||||
discover(resources2, reg2.capabilities, reg2.pattern_capabilities), resources2)
|
||||
assert state2['clean_level'] == 1
|
||||
|
||||
|
||||
def test_air_quality_absent_when_no_sensor_items():
|
||||
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
|
||||
RAC variant) binds no air-quality entities -- exists_fn gates each on its
|
||||
item type, not merely on the href being present."""
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
||||
assert '/sensors/vs/0' in resources # the href is there, just empty
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'):
|
||||
assert key not in state, key
|
||||
|
||||
|
||||
def test_sensor_item_value_picks_first_value():
|
||||
"""_sensor_item_value returns the first element of the value list, as a
|
||||
string; None when the item is absent or its value is empty."""
|
||||
items = [
|
||||
{'x.com.samsung.da.type': 'Dust', 'x.com.samsung.da.value': ['0', '0']},
|
||||
{'x.com.samsung.da.type': 'Odor', 'x.com.samsung.da.value': []},
|
||||
]
|
||||
assert airconditioner._sensor_item_value(items, 'Dust') == '0'
|
||||
assert airconditioner._sensor_item_value(items, 'Odor') is None
|
||||
assert airconditioner._sensor_item_value(items, 'Missing') is None
|
||||
assert airconditioner._sensor_item_value(None, 'Dust') is None
|
||||
|
||||
|
||||
def test_beep_and_tropical_night_stay_off_legacy_krac_board():
|
||||
"""ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_
|
||||
option token, but they're already modeled as buzzer_volume/good_sleep
|
||||
(see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also
|
||||
bind there, or the same options[] slot would surface as two entities."""
|
||||
reg, resources = _resolve('airconditioner_artik051_krac_18k')
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert 'beep' not in state
|
||||
assert 'tropical_night_mode' not in state
|
||||
assert state['buzzer_volume'] == 100.0
|
||||
assert state['good_sleep'] == 0.0
|
||||
|
||||
Reference in New Issue
Block a user