feat(airconditioner): add beep, tropical night, filter hours/threshold, air-quality, sw/fw version
Layer the ARTIK051_PRAC additive entities onto the upstream registry on a fresh branch (additive-only; the round-1 display-light/mute-once/WindFree work was independently shipped upstream and is not redone here). New entities, all driven by single-token option_write or item reads: - beep (switch): Volume_Mute/Volume_100 option token; single-token merge -- a full options RMW reverts on ARTIK051_PRAC. Cloud counterpart: samsungce.airConditionerAudioFeedback (on/off only; level is cloud-only). - tropical_night_mode (number 0-16): Sleep_<N> option token. Cloud: custom.airConditionerTropicalNightMode. - air_filter_usage_hours / air_filter_threshold (duration sensors, h): raw filterUsage count and filterDesiredUsage alarm threshold. Threshold SET is cloud-only (samsungce.dustFilterAlarm); local read-only. - clean_level / odor / dust / fine_dust (diagnostic sensors): /sensors/vs/0 items[]. No unit advertised on the resource, so no device_class until a populated reading + unit is observed (don't-guess rule). SuperFineDust intentionally not modeled. - software_version / firmware_version (diagnostic sensors): /information/vs/0 items[]. The href is globally ignored as identity plumbing; the AC registry drops that ignore so INFO is the sole cap on it. Translations: en.json + nl.json (every-language-mirrors-english invariant). Golden regression: regenerated the 8 AC fixtures + dehumidifier (reuses AIR_FILTER) for the new state keys. 608 tests pass.
This commit is contained in:
@@ -12,16 +12,23 @@ Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
# /information/vs/0 is globally ignored (serial/model identity plumbing), but
|
||||
# the AC exposes Software/Firmware version in its items[] -- model those here
|
||||
# and drop the no-entity coverage entry so INFO is the sole cap on the href.
|
||||
_AC_IGNORED = [c for c in ignored.IGNORED if c.href != '/information/vs/0']
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='airconditioner',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*_AC_IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
dishwasher.DIAGNOSIS,
|
||||
airconditioner.CLIMATE,
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.AIR_QUALITY,
|
||||
airconditioner.INFO,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
|
||||
@@ -15,10 +15,98 @@ different schema (see capabilities/__init__.py). They live only in the AC
|
||||
by_type registry.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _option_token(rep, prefix):
|
||||
"""Return the `<prefix>_<value>` token from /mode/vs/0 options, else None."""
|
||||
for o in _mode_options(rep):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o
|
||||
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."""
|
||||
tok = _option_token(rep, 'Volume')
|
||||
if tok is None:
|
||||
return None
|
||||
return tok != 'Volume_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)."""
|
||||
if payload not in ('On', 'Off'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Volume', '100' if payload == 'On' else 'Mute'),
|
||||
}
|
||||
|
||||
|
||||
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 or '_' not in tok:
|
||||
return None
|
||||
return _int(tok.split('_', 1)[1])
|
||||
|
||||
|
||||
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 _info_item_number(items, type_):
|
||||
"""x.com.samsung.da.number of the /information/vs/0 item with the given
|
||||
x.com.samsung.da.type ('Software'/'Firmware'), else None."""
|
||||
for it in (items or []):
|
||||
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
|
||||
return it.get('x.com.samsung.da.number')
|
||||
return None
|
||||
|
||||
|
||||
def _sensor_item_value(items, type_):
|
||||
"""First value of the /sensors/vs/0 item with the given
|
||||
x.com.samsung.da.type, as a diagnostic scalar string. The resource exposes
|
||||
no unit, so no device_class is set until a populated reading + unit is
|
||||
observed (the 'don't guess' rule)."""
|
||||
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
|
||||
@@ -37,6 +125,7 @@ HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
|
||||
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
|
||||
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
|
||||
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
|
||||
HREF_INFORMATION = '/information/vs/0' # model/serial + Software/Firmware version items
|
||||
|
||||
CLIMATE_CONSUMED_HREFS = [
|
||||
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
|
||||
@@ -201,6 +290,19 @@ 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.
|
||||
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 mode level (0-16) from the `Sleep_<N>` option token.
|
||||
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
|
||||
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
|
||||
exists_fn=lambda rep, resources: _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'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -239,6 +341,17 @@ AIR_FILTER = Capability(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
SensorDesc(key='air_filter_usage_hours',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
device_class='duration',
|
||||
state_class='measurement', unit='h',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=_int),
|
||||
SensorDesc(key='air_filter_threshold',
|
||||
field='x.com.samsung.da.filterDesiredUsage',
|
||||
device_class='duration',
|
||||
unit='h', icon='mdi:alarm', entity_category='diagnostic',
|
||||
value_fn=_int),
|
||||
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
@@ -341,6 +454,43 @@ HUMIDITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# /sensors/vs/0 items[] carry live air-quality readings (CleanLevel, Odor, Dust,
|
||||
# FineDust). The resource exposes no unit, so these are diagnostic scalars with
|
||||
# no device_class until a populated reading + unit is observed. Removed from
|
||||
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href.
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=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 (
|
||||
('clean_level', 'mdi:broom', 'CleanLevel'),
|
||||
('odor', 'mdi:weather-windy', 'Odor'),
|
||||
('dust', 'mdi:cloud', 'Dust'),
|
||||
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Software/Firmware version from /information/vs/0 items[] (the href is
|
||||
# globally ignored as identity plumbing; the AC registry drops that entry so
|
||||
# INFO is the sole cap on it). Diagnostic, read-only.
|
||||
INFO = Capability(
|
||||
href=HREF_INFORMATION,
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='software_version', field='x.com.samsung.da.items',
|
||||
icon='mdi:package-variant', entity_category='diagnostic',
|
||||
value_fn=lambda items: _info_item_number(items, 'Software')),
|
||||
SensorDesc(key='firmware_version', field='x.com.samsung.da.items',
|
||||
icon='mdi:chip', entity_category='diagnostic',
|
||||
value_fn=lambda items: _info_item_number(items, 'Firmware')),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
|
||||
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
|
||||
@@ -360,9 +510,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.
|
||||
|
||||
@@ -149,6 +149,9 @@
|
||||
},
|
||||
"target_humidity": {
|
||||
"name": "Target humidity"
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropical night mode"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -478,6 +481,18 @@
|
||||
"air_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filter usage hours"
|
||||
},
|
||||
"air_filter_threshold": {
|
||||
"name": "Filter alarm threshold"
|
||||
},
|
||||
"software_version": {
|
||||
"name": "Software version"
|
||||
},
|
||||
"firmware_version": {
|
||||
"name": "Firmware version"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Air sensing state"
|
||||
},
|
||||
@@ -729,6 +744,9 @@
|
||||
"auto_clean": {
|
||||
"name": "Auto clean"
|
||||
},
|
||||
"beep": {
|
||||
"name": "Beep"
|
||||
},
|
||||
"auto_door_opener": {
|
||||
"name": "Auto door opener"
|
||||
},
|
||||
|
||||
@@ -149,6 +149,9 @@
|
||||
},
|
||||
"target_humidity": {
|
||||
"name": "Doelvochtigheid"
|
||||
},
|
||||
"tropical_night_mode": {
|
||||
"name": "Tropische nachtmodus"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -478,6 +481,18 @@
|
||||
"air_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filterverbruik (uren)"
|
||||
},
|
||||
"air_filter_threshold": {
|
||||
"name": "Filteralarmdrempel"
|
||||
},
|
||||
"software_version": {
|
||||
"name": "Softwareversie"
|
||||
},
|
||||
"firmware_version": {
|
||||
"name": "Firmwareversie"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Status luchtmeting"
|
||||
},
|
||||
@@ -729,6 +744,9 @@
|
||||
"auto_clean": {
|
||||
"name": "Automatisch reinigen"
|
||||
},
|
||||
"beep": {
|
||||
"name": "Piep"
|
||||
},
|
||||
"auto_door_opener": {
|
||||
"name": "Automatische deuropener"
|
||||
},
|
||||
|
||||
+11
-1
@@ -1,17 +1,27 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"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",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"power_watts"
|
||||
"odor",
|
||||
"power_watts",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+7
-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,8 +14,11 @@
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
{
|
||||
"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",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
"selfcheck_status",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+7
-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",
|
||||
@@ -13,9 +16,12 @@
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"firmware_version",
|
||||
"mute_once",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
"selfcheck_status",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
{
|
||||
"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",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,16 +1,22 @@
|
||||
{
|
||||
"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",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
"power_watts",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,16 +1,26 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"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",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"power_watts"
|
||||
"odor",
|
||||
"power_watts",
|
||||
"software_version",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"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",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"firmware_version",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
"selfcheck_status",
|
||||
"software_version"
|
||||
]
|
||||
}
|
||||
|
||||
+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",
|
||||
|
||||
@@ -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
|
||||
@@ -405,3 +406,221 @@ def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
|
||||
desc = airconditioner.HUMIDITY.entities[0]
|
||||
rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'}
|
||||
assert desc.value_fn(rep.get(desc.field)) == 42.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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():
|
||||
"""On writes `['Volume_100']`, Off writes `['Volume_Mute']` -- one-element
|
||||
options array, not a full RMW (which reverts on ARTIK051_PRAC)."""
|
||||
write = _beep_desc().write_fn
|
||||
assert write('On', {}) == (
|
||||
['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 raw hour count (41 of 500); the existing
|
||||
air_filter_usage is the percentage (8%), this one is the raw hours."""
|
||||
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.unit == 'h' and desc.device_class == 'duration'
|
||||
|
||||
|
||||
def test_air_filter_threshold_reads_desired_usage():
|
||||
"""filterDesiredUsage is the alarm threshold in hours (read-only local;
|
||||
setting it is cloud-only via samsungce.dustFilterAlarm)."""
|
||||
desc = next(e for e in airconditioner.AIR_FILTER.entities
|
||||
if e.key == 'air_filter_threshold')
|
||||
assert desc.value_fn('500') == 500
|
||||
assert desc.value_fn(500) == 500
|
||||
assert desc.value_fn(None) is None
|
||||
assert getattr(desc, 'write_fn', None) is None
|
||||
assert desc.unit == 'h' and desc.device_class == 'duration'
|
||||
|
||||
|
||||
def test_air_filter_hours_and_threshold_in_state():
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['air_filter_usage_hours'] == 41
|
||||
assert state['air_filter_threshold'] == 500
|
||||
assert state['air_filter_usage'] == 8 # 41/500 -> 8%
|
||||
|
||||
|
||||
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)."""
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
for key in ('clean_level', 'odor', 'dust', 'fine_dust'):
|
||||
assert key in state, key
|
||||
assert state[key] == '0'
|
||||
# SuperFineDust exists in the dump but is deliberately not modeled.
|
||||
assert 'super_fine_dust' not in state
|
||||
|
||||
|
||||
def test_air_quality_absent_when_no_sensor_items():
|
||||
"""Boards whose /sensors/vs/0 has no items[] (TP1X_DA-AC-RAC-01001) bind no
|
||||
air-quality entities -- exists_fn gates each on its item type."""
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac')
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
for key in ('clean_level', 'odor', 'dust', '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_software_and_firmware_version_from_info_items():
|
||||
"""/information/vs/0 items[] carry Software/Firmware version strings
|
||||
(the href is otherwise identity plumbing; the AC registry drops the global
|
||||
ignore so INFO is the sole cap on it)."""
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state['software_version'] == '02181A230313'
|
||||
assert state['firmware_version'] == '20082000,FFFFFFFF'
|
||||
|
||||
|
||||
def test_info_item_number_picks_first_match():
|
||||
"""_info_item_number returns the number of the first item with the given
|
||||
type; None when absent. Multiple Firmware items -> first wins."""
|
||||
items = [
|
||||
{'x.com.samsung.da.type': 'Software', 'x.com.samsung.da.number': 'SW1'},
|
||||
{'x.com.samsung.da.type': 'Firmware', 'x.com.samsung.da.number': 'FW1'},
|
||||
{'x.com.samsung.da.type': 'Firmware', 'x.com.samsung.da.number': 'FW2'},
|
||||
]
|
||||
assert airconditioner._info_item_number(items, 'Software') == 'SW1'
|
||||
assert airconditioner._info_item_number(items, 'Firmware') == 'FW1'
|
||||
assert airconditioner._info_item_number(items, 'Outdoor') is None
|
||||
assert airconditioner._info_item_number(None, 'Software') is None
|
||||
|
||||
|
||||
def test_info_drops_global_ignore_on_information_href():
|
||||
"""The AC registry drops /information/vs/0 from ignored.IGNORED so INFO is
|
||||
the sole capability on the href (no no-entity coverage cap duplicates)."""
|
||||
reg, _ = _ac()
|
||||
caps = reg.capabilities.get('/information/vs/0')
|
||||
assert caps, '/information/vs/0'
|
||||
# INFO is the only cap on the href, and it carries real entities.
|
||||
assert len(caps) == 1
|
||||
assert caps[0] is airconditioner.INFO
|
||||
assert airconditioner.INFO.entities != ()
|
||||
|
||||
Reference in New Issue
Block a user