refactor(i18n): drop the vestigial descriptor name field
Since entity.py started routing named descriptors through the catalog under desc.key, SamsungEntityDescription.name has been read for its value nowhere -- only twice as a flag, to decide whether an entity was translated at all. That left 148 English names duplicated between Python and translations/en.json with nothing keeping them honest: six had already drifted, invisibly, because editing the Python side changes nothing a user sees. So the field is gone, and translation_key defaults to desc.key. A descriptor now sets translation_key only to share one catalog entry across descriptors or to point at a differently-named one, and the catalog is the only place an entity name exists. Every descriptor resolves to exactly the translation key, icon, entity category, enabled-default and gating it did before -- with one deliberate exception: the hood fan, previously the sole descriptor with no key at all, now resolves to 'fan'. That is inert, because fan.py sets _attr_name = None so the entity presents as the device itself. The three helpers that forwarded a name into a descriptor (laundry.bool_option_switch, washer._bool_option_switch, air_purifier's sensor table) lose that parameter. test_translations.py now requires a catalog entry for every descriptor rather than only translated ones. Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
This commit is contained in:
@@ -103,21 +103,44 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
|
||||
## 5. Enum selects need translation support
|
||||
## 5. Names and enum labels live in translations, never in Python
|
||||
|
||||
Any select whose options are raw device codes (course/cycle, and code-valued
|
||||
settings) must render through translations, not Python:
|
||||
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
|
||||
`options`/`options_field` supply the **raw** codes.
|
||||
- Add the labels to `translations/en.json` under
|
||||
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
|
||||
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
|
||||
the cue to identify and name them.
|
||||
- `en.json` is the only place these live: there is no `strings.json` (Home
|
||||
Assistant doesn't read one from a custom integration), and `select.py`
|
||||
derives its normalization set from the catalog, so there is no Python list
|
||||
to keep in sync. Every other language must mirror `en.json` key for key —
|
||||
`tests/test_translations.py` enforces that.
|
||||
Descriptors have **no `name` field**. Every entity is named from the shipped
|
||||
catalog, keyed by `translation_key` — which defaults to the descriptor's own
|
||||
`key`. So adding `SensorDesc(key='filter_status', ...)` obliges you to add:
|
||||
|
||||
```json
|
||||
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
|
||||
```
|
||||
|
||||
to `translations/en.json`. Skip it and the entity ships nameless;
|
||||
`tests/test_translations.py` fails the build instead.
|
||||
|
||||
- **Sentence case** ("Filter status", not "Filter Status"), per HA's style
|
||||
guide — capitalize only proper nouns and Samsung feature names ("AI Energy
|
||||
Mode", "Storm Wash+").
|
||||
- Set `translation_key` explicitly only to **share** one catalog entry across
|
||||
descriptors, or to point at a differently-named one. Two descriptors on the
|
||||
same platform with the same `key` already share an entry — intended for
|
||||
`common.py`'s OCF/vendor fallback pairs, a silent mislabel otherwise.
|
||||
- Prefer HA's own vocabulary where it fits: a `device_class` gives you
|
||||
translated states for free (`binary_sensor` door/running, `sensor`
|
||||
timestamp/enum), so don't restate them.
|
||||
|
||||
Selects whose options are raw device codes (course/cycle, code-valued
|
||||
settings) additionally need those codes labelled:
|
||||
- `options`/`options_field` supply the **raw** codes; the catalog maps them.
|
||||
- Add labels under `entity.select.<translation_key>.state.<code>`, code
|
||||
**lowercased** (e.g. `"16": "Cotton"`). `select.py` derives which values it
|
||||
normalizes from the catalog itself, so there is no Python list to keep in
|
||||
sync — a code with no entry simply renders as the raw code, which is the cue
|
||||
to identify and name it.
|
||||
|
||||
`translations/en.json` is the only place any of this lives: there is no
|
||||
`strings.json` (Home Assistant doesn't read one from a custom integration) and
|
||||
no `[%key:...%]` resolution (that's Core build tooling). Every other language
|
||||
must mirror `en.json` key for key — also enforced by
|
||||
`tests/test_translations.py`.
|
||||
|
||||
## 6. Coverage discipline: bound or ignored
|
||||
|
||||
|
||||
@@ -38,10 +38,14 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
|
||||
|
||||
def _derive_name(state_key: str) -> str:
|
||||
"""Turn a snake_case state key into a title-cased display name.
|
||||
"""Turn a snake_case state key into a title-cased label.
|
||||
|
||||
Strips a trailing instance number of 0 (singleton), promotes any other
|
||||
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
|
||||
|
||||
Entity names themselves come from the translation catalog; this only
|
||||
builds the {instance_name} placeholder those translations interpolate,
|
||||
for a device that named its own compartments/ice makers.
|
||||
"""
|
||||
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
|
||||
return _snake_to_title(name).strip()
|
||||
@@ -79,21 +83,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
"instance_name": _instance_display_name(bound, self._state_key)
|
||||
}
|
||||
|
||||
# Do not set _attr_name for translated entities: Home Assistant gives
|
||||
# an explicitly-set name precedence over the translation catalog. The
|
||||
# English descriptor name remains a fallback for descriptions without
|
||||
# a translation key, while existing registry entity IDs stay intact via
|
||||
# their unchanged unique IDs.
|
||||
has_translation = (
|
||||
bound.desc.translation_key is not None or bound.desc.name is not None
|
||||
)
|
||||
if not has_translation:
|
||||
if bound.instance_name:
|
||||
self._attr_name = (
|
||||
f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
|
||||
)
|
||||
else:
|
||||
self._attr_name = _derive_name(self._state_key)
|
||||
# _attr_name is deliberately left unset: Home Assistant gives an
|
||||
# explicitly-set name precedence over the translation catalog, so
|
||||
# setting it here would make every entity untranslatable. Every
|
||||
# descriptor resolves to a catalog entry (see translation_key below);
|
||||
# a platform that wants the bare device name instead sets
|
||||
# _attr_name = None itself, as fan.py does for the hood's main entity.
|
||||
self._attr_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||
@@ -101,11 +96,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
|
||||
@property
|
||||
def translation_key(self) -> str | None:
|
||||
"""Override Entity.translation_key (a property upstream, not a
|
||||
plain attribute) so a callable descriptor -- e.g.
|
||||
laundry.cycle_select's table-id-gated resolver -- is re-evaluated
|
||||
against live coordinator data on every access, not resolved once
|
||||
at construction time.
|
||||
"""The descriptor's catalog key, defaulting to its own `key`.
|
||||
|
||||
Overrides Entity.translation_key (a property upstream, not a plain
|
||||
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
|
||||
table-id-gated resolver -- is re-evaluated against live coordinator
|
||||
data on every access, not resolved once at construction time.
|
||||
|
||||
Discovery runs on the first /device/0 poll, which the entity
|
||||
registry already documents can hand a sibling resource an empty
|
||||
@@ -118,9 +114,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
tk = self._bound.desc.translation_key
|
||||
if callable(tk):
|
||||
return tk(self.coordinator.last_resources)
|
||||
if tk is not None:
|
||||
return tk
|
||||
return self._bound.desc.key if self._bound.desc.name is not None else None
|
||||
return tk if tk is not None else self._bound.desc.key
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
|
||||
@@ -47,20 +47,20 @@ from .common import int_or_none, sensor_item_value
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, replace_in_options
|
||||
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
('dust', 'Dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'),
|
||||
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'),
|
||||
('odor', 'Odor', 'mdi:scent', 'Odor'),
|
||||
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'),
|
||||
('dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'mdi:blur', 'FineDust'),
|
||||
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
|
||||
('odor', 'mdi:scent', 'Odor'),
|
||||
('clean_level', 'mdi:air-filter', 'CleanLevel'),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon,
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ FILTER = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
|
||||
name='Filter progress', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
@@ -97,7 +97,7 @@ DEVICE_ACTIVE = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
|
||||
name='Device active', icon='mdi:check-network-outline',
|
||||
icon='mdi:check-network-outline',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
@@ -110,10 +110,10 @@ AIRFLOW_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='speed',
|
||||
name='Fan speed level', icon='mdi:fan',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic'),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
name='Fan direction', icon='mdi:rotate-3d-variant',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
@@ -124,11 +124,11 @@ AIRFLOW_VS_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
|
||||
name='Fan speed level', icon='mdi:fan',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
|
||||
name='Fan direction', icon='mdi:rotate-3d-variant',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
@@ -145,14 +145,14 @@ MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on',
|
||||
SwitchDesc(key='display_light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
rep_fn=bool_option_value('Light'),
|
||||
exists_fn=bool_option_exists('Light'),
|
||||
write_fn=_light_write),
|
||||
# Read-only -- confirmed NOT the fan-speed selector (see module
|
||||
# docstring), actual purpose still unconfirmed.
|
||||
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan',
|
||||
SensorDesc(key='operating_mode', icon='mdi:fan',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
|
||||
exists_fn=bool_option_exists('Comode')),
|
||||
|
||||
@@ -109,7 +109,7 @@ AIR_PURIFY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
|
||||
name='Air purification', icon='mdi:air-purifier',
|
||||
icon='mdi:air-purifier',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -123,7 +123,7 @@ AUTO_CLEAN = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
|
||||
name='Auto clean', icon='mdi:spray-bottle',
|
||||
icon='mdi:spray-bottle',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -137,10 +137,10 @@ AIR_FILTER = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
|
||||
name='Filter usage', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status', device_class='enum',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='filter_status',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
@@ -153,7 +153,7 @@ DISPLAY_LIGHT = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', field='mode',
|
||||
name='Display light', icon='mdi:led-on',
|
||||
icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -170,7 +170,7 @@ MUTE_ONCE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='mute_once', field='muteonce',
|
||||
name='Mute once', icon='mdi:volume-mute',
|
||||
icon='mdi:volume-mute',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -190,11 +190,11 @@ CURRENT_LIMIT = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='current_limit_enabled', field='operation',
|
||||
name='Current limit enabled', icon='mdi:current-ac',
|
||||
icon='mdi:current-ac',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
SensorDesc(key='current_limit_level', field='modes',
|
||||
name='Current limit level', icon='mdi:current-ac',
|
||||
icon='mdi:current-ac',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -102,7 +102,6 @@ POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
name='Power',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
@@ -114,7 +113,6 @@ POWER_VS_FALLBACK = Capability(
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
@@ -126,7 +124,7 @@ KIDS_LOCK_GENERIC = Capability(
|
||||
href='/kidslock/0',
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='value',
|
||||
name='Child lock', device_class='lock',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', '0'], {'value': p == 'On'})),
|
||||
@@ -138,7 +136,7 @@ KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
name='Child lock', device_class='lock',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: v != 'Ready',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', 'vs', '0'],
|
||||
@@ -172,7 +170,7 @@ REMOTE_CONTROL_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
@@ -184,7 +182,7 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
),
|
||||
)
|
||||
@@ -194,7 +192,7 @@ ALARMS = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
|
||||
name='Alarm code', icon='mdi:alert',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic', value_fn=_active_alarm_codes),
|
||||
),
|
||||
)
|
||||
@@ -217,13 +215,13 @@ ENERGY_METER = Capability(
|
||||
# entity when /device/0 returns a not-yet-fetched stub. On a populated
|
||||
# rep, hide power only for the dead sentinel or an absent field.
|
||||
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||
name='Power', device_class='power', state_class='measurement',
|
||||
device_class='power', state_class='measurement',
|
||||
unit='W', value_fn=clamp_power,
|
||||
exists_fn=lambda rep, resources: not rep or (
|
||||
rep.get('x.com.samsung.da.instantaneousPower')
|
||||
not in (None, _DEAD_INSTANTANEOUS_POWER))),
|
||||
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||
name='Energy', device_class='energy',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
|
||||
@@ -234,7 +232,7 @@ ENERGY_METER = Capability(
|
||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
||||
# entity if setup happens to land on a not-yet-fetched stub.
|
||||
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
||||
name='Power energy', device_class='energy',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||
@@ -242,7 +240,7 @@ ENERGY_METER = Capability(
|
||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
|
||||
name='Energy saved', device_class='energy',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||
@@ -250,12 +248,12 @@ ENERGY_METER = Capability(
|
||||
# in-progress current month. Not ever-increasing (each resets at
|
||||
# month boundary), so no state_class.
|
||||
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
|
||||
name='Energy (last month)', device_class='energy',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
||||
name='Energy (this month)', device_class='energy',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||
@@ -266,7 +264,7 @@ WATER_METER = Capability(
|
||||
href='/water/consumption/vs/0',
|
||||
entities=(
|
||||
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
|
||||
name='Water consumption', device_class='water',
|
||||
device_class='water',
|
||||
state_class='total_increasing', unit='L', icon='mdi:water',
|
||||
value_fn=_ml_to_l),
|
||||
),
|
||||
@@ -277,10 +275,10 @@ WATER_FILTER = Capability(
|
||||
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
|
||||
entities=(
|
||||
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
|
||||
name='Filter usage', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:filter'),
|
||||
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status', icon='mdi:filter-check',
|
||||
icon='mdi:filter-check',
|
||||
device_class='enum', options=('normal', 'wash', 'replace'),
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
@@ -348,14 +346,14 @@ AI_ENERGY_LEVEL = Capability(
|
||||
# cold-tier href, the same reload already required to fix which
|
||||
# platform got picked in that case.
|
||||
SwitchDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI Energy Mode', icon='mdi:leaf',
|
||||
icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != '0',
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) == 1),
|
||||
write_fn=_ai_energy_level_switch_write),
|
||||
SelectDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI Energy Mode level', icon='mdi:leaf',
|
||||
icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
options=_ai_energy_level_options,
|
||||
exists_fn=lambda rep, resources: (
|
||||
@@ -371,7 +369,6 @@ FIRMWARE_UPDATE = Capability(
|
||||
BinarySensorDesc(
|
||||
key='firmware_update',
|
||||
field='x.com.samsung.da.newVersionAvailable',
|
||||
name='Firmware update available',
|
||||
device_class='update',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||
@@ -384,21 +381,21 @@ SELF_CHECK = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||
name='Self-check status', icon='mdi:stethoscope',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||
name='Self-check result', icon='mdi:clipboard-check-outline',
|
||||
icon='mdi:clipboard-check-outline',
|
||||
entity_category='diagnostic'),
|
||||
# List of error codes from the last self-check; joined for display.
|
||||
# Not every fridge reports the field, hence the exists_fn.
|
||||
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
||||
name='Self-check error', icon='mdi:alert-circle-outline',
|
||||
icon='mdi:alert-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.error' in rep),
|
||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
ButtonDesc(key='selfcheck_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
||||
|
||||
@@ -61,7 +61,6 @@ COOKTOP_POWER = Capability(
|
||||
BinarySensorDesc(
|
||||
key='power_state',
|
||||
field='x.com.samsung.da.power',
|
||||
name='Power state',
|
||||
device_class='power',
|
||||
icon='mdi:stove',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
@@ -83,7 +82,6 @@ COOKTOP_MODE = Capability(
|
||||
BinarySensorDesc(
|
||||
key='any_burner_active',
|
||||
field='x.com.samsung.da.options',
|
||||
name='Any burner active',
|
||||
device_class='running',
|
||||
icon='mdi:fire',
|
||||
value_fn=_any_burner_active,
|
||||
@@ -92,7 +90,6 @@ COOKTOP_MODE = Capability(
|
||||
SensorDesc(
|
||||
key=f'burner_{slot}_state',
|
||||
field='x.com.samsung.da.options',
|
||||
name=f'Burner {slot} state',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(slot)},
|
||||
icon='mdi:gas-burner',
|
||||
@@ -111,14 +108,12 @@ COOKTOP_MODE = Capability(
|
||||
SensorDesc(
|
||||
key='main_timer_state',
|
||||
field='x.com.samsung.da.options',
|
||||
name='Timer state',
|
||||
icon='mdi:timer-outline',
|
||||
value_fn=lambda options: _option_value(options, 'MainTimerState'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_current',
|
||||
field='x.com.samsung.da.options',
|
||||
name='Timer current value',
|
||||
icon='mdi:timer-sand',
|
||||
enabled_default=False,
|
||||
value_fn=lambda options: _int_or_none(
|
||||
@@ -136,7 +131,6 @@ COOKTOP_CONNECTED = Capability(
|
||||
BinarySensorDesc(
|
||||
key='cloud_connected',
|
||||
field='x.com.samsung.da.connected',
|
||||
name='Cloud connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
@@ -152,35 +146,30 @@ PAIRED_HOOD_STATUS = Capability(
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_connected',
|
||||
field='connectionState',
|
||||
name='Paired hood connected',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda value: str(value).lower() == 'connected',
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_power',
|
||||
field='power',
|
||||
name='Paired hood power',
|
||||
device_class='running',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_fan_speed',
|
||||
field='fanSpeed',
|
||||
name='Paired hood fan speed',
|
||||
icon='mdi:fan',
|
||||
value_fn=_int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_light',
|
||||
field='lampState',
|
||||
name='Paired hood light',
|
||||
device_class='light',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_model',
|
||||
field='micomModelId',
|
||||
name='Paired hood model',
|
||||
icon='mdi:information-outline',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
@@ -188,7 +177,6 @@ PAIRED_HOOD_STATUS = Capability(
|
||||
SensorDesc(
|
||||
key='paired_hood_firmware',
|
||||
field='firmwareVersion',
|
||||
name='Paired hood firmware',
|
||||
icon='mdi:chip',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
|
||||
@@ -18,13 +18,13 @@ DISHWASHER_SETTINGS = Capability(
|
||||
href='/dishwasher/vs/0',
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
name='Sanitize', icon='mdi:bacteria',
|
||||
icon='mdi:bacteria',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
|
||||
name='Smart Dry', icon='mdi:heat-wave',
|
||||
icon='mdi:heat-wave',
|
||||
options_field='x.com.samsung.da.supportedHeatedDry',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
@@ -45,9 +45,9 @@ CYCLE_OPTIONS = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
||||
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
|
||||
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
|
||||
'StormWashZone'),
|
||||
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
|
||||
bool_option_switch('auto_release_dry', 'mdi:door-open',
|
||||
'AutoDoorRelease', gate_on_presence=True),
|
||||
),
|
||||
)
|
||||
@@ -61,10 +61,10 @@ DIAGNOSIS = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
|
||||
name='Diagnosis status', icon='mdi:stethoscope',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
ButtonDesc(key='diagnosis_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
|
||||
@@ -76,7 +76,7 @@ OPERATION_ORIGIN = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='operation_origin', field='origin',
|
||||
name='Last operation source', icon='mdi:remote',
|
||||
icon='mdi:remote',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -24,13 +24,13 @@ DRYER_SETTINGS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:water-percent'),
|
||||
icon='mdi:water-percent'),
|
||||
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
|
||||
name='Dry time', icon='mdi:timer'),
|
||||
icon='mdi:timer'),
|
||||
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
|
||||
name='Dryer type', icon='mdi:tumble-dryer'),
|
||||
icon='mdi:tumble-dryer'),
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
name='Wrinkle prevent', icon='mdi:iron',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
),
|
||||
@@ -58,6 +58,6 @@ DRYER_DIAGNOSIS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
|
||||
name='Diagnosis', entity_category='diagnostic'),
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='temperature', field='temperature',
|
||||
name=None, translation_key='instance_temperature',
|
||||
translation_key='instance_temperature',
|
||||
use_instance_name=True, icon='mdi:thermometer',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
state_class='measurement'),
|
||||
@@ -69,7 +69,7 @@ TEMP_SETPOINT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='temperature',
|
||||
name=None, translation_key='instance_setpoint',
|
||||
translation_key='instance_setpoint',
|
||||
use_instance_name=True, device_class='temperature', unit_fn=_temp_unit,
|
||||
native_min=-20.0, native_max=50.0,
|
||||
range_field='range', entity_category='config',
|
||||
@@ -89,7 +89,7 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='ice_night_mode', field='ice.night.status',
|
||||
name='Nighttime ice quiet mode', icon='mdi:weather-night',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -103,8 +103,9 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||
#
|
||||
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
|
||||
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") -- read via name_field, reaching the translated name as the
|
||||
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
|
||||
# ice makers are independent on/off toggles that can both be enabled at once
|
||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
||||
# select, but users still want them labeled with the device's own names.
|
||||
@@ -125,18 +126,18 @@ ICEMAKER_GENERIC = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='making_status',
|
||||
field='x.com.samsung.da.iceMaker.iceMakingStatus',
|
||||
name=None, use_instance_name=True, icon='mdi:cube-outline',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
device_class='enum',
|
||||
options=('icestatus_stop', 'icestatus_run'),
|
||||
translation_key='ice_making_status',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
|
||||
name=None, translation_key='instance_enabled',
|
||||
translation_key='instance_enabled',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
|
||||
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
|
||||
name=None, use_instance_name=True, icon='mdi:cube-outline',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
translation_key='ice_type',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.iceType.supported',
|
||||
@@ -154,7 +155,7 @@ DOOR_ALERT = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='door_alert', field='alert.door',
|
||||
name='Door alarm', icon='mdi:bell-alert',
|
||||
icon='mdi:bell-alert',
|
||||
translation_key='door_alert',
|
||||
entity_category='config',
|
||||
options_field='supportedAlert.door',
|
||||
@@ -180,12 +181,12 @@ STATUS_LOCK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol',
|
||||
name='Auto door opener', icon='mdi:door-open',
|
||||
icon='mdi:door-open',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
|
||||
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
|
||||
name='Sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
|
||||
@@ -210,7 +211,7 @@ DEFROST_DELAY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost',
|
||||
name='Defrost delay', icon='mdi:snowflake-off',
|
||||
icon='mdi:snowflake-off',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -232,7 +233,7 @@ DEFROST_BLOCK_STATUS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
||||
),
|
||||
@@ -255,12 +256,12 @@ REFRIGERATION = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
|
||||
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||
icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
|
||||
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
|
||||
name='Rapid freezing', icon='mdi:snowflake',
|
||||
icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
|
||||
@@ -282,7 +283,7 @@ AUTOFILL = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
|
||||
name='Autofill pitcher', icon='mdi:cup-water',
|
||||
icon='mdi:cup-water',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_autofill_write),
|
||||
@@ -298,7 +299,7 @@ WELCOME_LIGHTING = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='welcome_lighting', field='status',
|
||||
name='Welcome lighting', icon='mdi:motion-sensor',
|
||||
icon='mdi:motion-sensor',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -389,7 +390,7 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='day_brightness', field='level.brightness.daytime',
|
||||
name='Cabinet brightness', icon='mdi:brightness-5',
|
||||
icon='mdi:brightness-5',
|
||||
translation_key='day_brightness',
|
||||
entity_category='config',
|
||||
options=_NIGHT_BRIGHTNESS_OPTIONS,
|
||||
@@ -397,7 +398,7 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
['cabinet', 'light', 'enhanced', 'vs', '0'],
|
||||
{'level.brightness.daytime': p})),
|
||||
SelectDesc(key='brightness_level', field='level.brightness.nighttime',
|
||||
name='Night brightness', icon='mdi:brightness-4',
|
||||
icon='mdi:brightness-4',
|
||||
translation_key='brightness_level',
|
||||
entity_category='config',
|
||||
options=_NIGHT_BRIGHTNESS_OPTIONS,
|
||||
@@ -405,12 +406,12 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
['cabinet', 'light', 'enhanced', 'vs', '0'],
|
||||
{'level.brightness.nighttime': p})),
|
||||
TimeDesc(key='night_start', field='',
|
||||
name='Night light start', icon='mdi:clock-start',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
rep_fn=_night_start_value,
|
||||
write_fn=_night_start_write),
|
||||
TimeDesc(key='night_end', field='',
|
||||
name='Night light end', icon='mdi:clock-end',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
rep_fn=_night_end_value,
|
||||
write_fn=_night_end_write),
|
||||
@@ -434,11 +435,11 @@ CABINET_LIGHT = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl',
|
||||
name='Cabinet light', icon='mdi:fridge-outline',
|
||||
icon='mdi:fridge-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_cabinet_light_write),
|
||||
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
|
||||
name='Brighten gradually', icon='mdi:brightness-auto',
|
||||
icon='mdi:brightness-auto',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -462,7 +463,7 @@ SABBATH = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
|
||||
name='Sabbath mode', icon='mdi:hands-pray',
|
||||
icon='mdi:hands-pray',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_sabbath_write),
|
||||
@@ -482,7 +483,7 @@ BEVERAGE_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
|
||||
name='Beverage zone mode', icon='mdi:glass-wine',
|
||||
icon='mdi:glass-wine',
|
||||
translation_key='beverage_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='roomSupportedModes',
|
||||
@@ -510,7 +511,7 @@ PANTRY_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
|
||||
name='Pantry zone mode', icon='mdi:glass-wine',
|
||||
icon='mdi:glass-wine',
|
||||
translation_key='pantry_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
@@ -557,7 +558,7 @@ FLEX_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='flex_zone_mode',
|
||||
name='Flex zone mode', icon='mdi:thermostat',
|
||||
icon='mdi:thermostat',
|
||||
translation_key='flex_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
@@ -578,7 +579,7 @@ DOOR_GENERIC = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='open', field='openState',
|
||||
name=None, translation_key='instance_open',
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door',
|
||||
value_fn=lambda v: v == 'Open'),
|
||||
),
|
||||
@@ -608,7 +609,7 @@ DOORS_FALLBACK = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
name='Door', device_class='door',
|
||||
device_class='door',
|
||||
value_fn=lambda items: any(
|
||||
i.get('x.com.samsung.da.openState') == 'Open'
|
||||
for i in (items or []))),
|
||||
@@ -640,13 +641,13 @@ TEMPERATURES_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
||||
name='Freezer temperature', icon='mdi:thermometer',
|
||||
icon='mdi:thermometer',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Freezer'),
|
||||
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
|
||||
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
||||
name='Fridge temperature', icon='mdi:thermometer',
|
||||
icon='mdi:thermometer',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Fridge'),
|
||||
@@ -669,7 +670,7 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker',
|
||||
name='Ice maker', icon='mdi:cube-outline',
|
||||
icon='mdi:cube-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'status', 'vs', '0'],
|
||||
@@ -699,17 +700,17 @@ REFRIGERATION_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_active', field='defrost',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
|
||||
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||
icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
|
||||
name='Rapid freezing', icon='mdi:snowflake',
|
||||
icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
|
||||
@@ -61,30 +61,30 @@ DOOR_LED = Capability(
|
||||
href='/doorled/light/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='led_brightness', field='setBrightness',
|
||||
name='Door LED brightness', icon='mdi:brightness-6',
|
||||
icon='mdi:brightness-6',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS, write_fn=_led_brightness_write),
|
||||
SwitchDesc(key='led_night_light', field='setNightLight',
|
||||
name='Door LED night light', icon='mdi:weather-night',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_led_night_write),
|
||||
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
|
||||
name='Door LED night brightness', icon='mdi:brightness-4',
|
||||
icon='mdi:brightness-4',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightBrightness': p})),
|
||||
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
|
||||
name='Door LED night start', icon='mdi:clock-start',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
|
||||
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
|
||||
name='Door LED night end', icon='mdi:clock-end',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -97,7 +97,7 @@ SOUND_MODE = Capability(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', field='mode',
|
||||
name='Sound mode', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options=_SOUND_MODES, write_fn=_sound_mode_write),
|
||||
),
|
||||
@@ -107,7 +107,7 @@ SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
name='Sound volume', icon='mdi:volume-medium',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
native_min=0, native_max=15, step=5,
|
||||
value_fn=lambda v: int(v) if v is not None else None,
|
||||
@@ -128,13 +128,13 @@ BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
name='Buzzer sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
name='Finish sound', icon='mdi:bell-ring',
|
||||
icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
@@ -320,7 +320,7 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
return candidate if has_entity_translation('select', candidate) else 'cycle'
|
||||
|
||||
return SelectDesc(
|
||||
key='cycle', name='Cycle', icon=icon, translation_key=key,
|
||||
key='cycle', icon=icon, translation_key=key,
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
@@ -360,8 +360,8 @@ def bool_option_exists(prefix):
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
|
||||
|
||||
def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||
|
||||
gate_on_presence self-gates the entity off on models that never report
|
||||
@@ -373,7 +373,7 @@ def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
|
||||
building one, if needed, is the caller's job.
|
||||
"""
|
||||
return SwitchDesc(
|
||||
key=key, name=name, icon=icon, entity_category=entity_category,
|
||||
key=key, icon=icon, entity_category=entity_category,
|
||||
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
||||
rep_fn=bool_option_value(prefix),
|
||||
write_fn=bool_option_write(prefix),
|
||||
@@ -396,7 +396,6 @@ JOB_BEGINNING_STATUS = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ def _finish_time(remaining_str):
|
||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||
# rather than keeping its own copy, since both wrote the identical
|
||||
# state='Ready' RMW.
|
||||
STOP_BUTTON = ButtonDesc(key='stop', field='', name='Stop', payload='Ready',
|
||||
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
@@ -84,7 +84,7 @@ OPERATIONAL_STATE = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', device_class='enum',
|
||||
device_class='enum',
|
||||
options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
# cycle_active is a bool derived from machine_state; used by the
|
||||
@@ -92,23 +92,22 @@ OPERATIONAL_STATE = Capability(
|
||||
# Harmless for non-oven appliances — just an extra bool in state.
|
||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
||||
# Named 'Running' rather than 'Cycle active' -- this href (and the
|
||||
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
|
||||
# start/pause/stop buttons below) is shared across the dryer/
|
||||
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
|
||||
# vocabulary that doesn't fit an oven's bake/roast/etc.
|
||||
BinarySensorDesc(key='cycle_active', device_class='running',
|
||||
name='Running',
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
||||
)),
|
||||
SensorDesc(key='progress', name='Progress', icon='mdi:progress-wrench',
|
||||
SensorDesc(key='progress', icon='mdi:progress-wrench',
|
||||
rep_fn=lambda rep: (
|
||||
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
SensorDesc(key='progress_percentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
@@ -117,13 +116,12 @@ OPERATIONAL_STATE = Capability(
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
SensorDesc(key='finish_time', device_class='timestamp',
|
||||
name='Estimated finish',
|
||||
rep_fn=lambda rep: (
|
||||
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
||||
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
||||
)),
|
||||
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline',
|
||||
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
|
||||
device_class='duration', unit='h',
|
||||
native_min=0, native_max=24, step=1,
|
||||
rep_fn=lambda rep: _delay_hours(
|
||||
@@ -132,12 +130,12 @@ OPERATIONAL_STATE = Capability(
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{_delay_field(rep): _format_delay(p)})),
|
||||
ButtonDesc(key='start', field='', name='Start', payload='Run',
|
||||
ButtonDesc(key='start', field='', payload='Run',
|
||||
icon='mdi:play',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
ButtonDesc(key='pause', field='', name='Pause', payload='Pause',
|
||||
ButtonDesc(key='pause', field='', payload='Pause',
|
||||
icon='mdi:pause',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
|
||||
@@ -227,25 +227,25 @@ OVEN_OPERATIONAL_STATE = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', icon='mdi:stove',
|
||||
icon='mdi:stove',
|
||||
device_class='enum', options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
|
||||
name='Running', device_class='running',
|
||||
device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
SensorDesc(key='operation_time_minutes',
|
||||
field='x.com.samsung.da.operationTime',
|
||||
name='Operation time', unit='min',
|
||||
unit='min',
|
||||
state_class='measurement', value_fn=_op_minutes),
|
||||
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
|
||||
name='Estimated finish', device_class='timestamp',
|
||||
device_class='timestamp',
|
||||
value_fn=_finish_time),
|
||||
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
|
||||
name='Cook time', unit='min', native_min=0, native_max=1439,
|
||||
unit='min', native_min=0, native_max=1439,
|
||||
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
|
||||
write_fn=_cook_time_write),
|
||||
STOP_BUTTON,
|
||||
@@ -257,7 +257,7 @@ OVEN_CAVITY = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
|
||||
name='Cavity state'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -290,7 +290,7 @@ OVEN_SETPOINT = Capability(
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
@@ -300,7 +300,7 @@ OVEN_SETPOINT = Capability(
|
||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||
write_fn=_oven_setpoint_write),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
name='Temperature', device_class='temperature',
|
||||
device_class='temperature',
|
||||
state_class='measurement', unit_fn=_oven_temp_unit,
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
@@ -312,7 +312,7 @@ OVEN_DOOR = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
name='Door', device_class='door',
|
||||
device_class='door',
|
||||
value_fn=lambda items: (
|
||||
items[0].get('x.com.samsung.da.openState') == 'Open'
|
||||
if items else None)),
|
||||
@@ -324,7 +324,7 @@ OVEN_CONNECTED = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
|
||||
name='Cloud connected', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
),
|
||||
@@ -343,25 +343,25 @@ OVEN_MODE = Capability(
|
||||
entities=(
|
||||
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
|
||||
name='Cooking mode', icon='mdi:tune',
|
||||
icon='mdi:tune',
|
||||
options=_OVEN_MODES,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
name='Lamp', icon='mdi:track-light',
|
||||
icon='mdi:track-light',
|
||||
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
|
||||
write_fn=_lamp_write),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
name='Sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
|
||||
write_fn=_sound_write),
|
||||
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
|
||||
name='Fast preheat', icon='mdi:fire',
|
||||
icon='mdi:fire',
|
||||
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
|
||||
write_fn=_fastpreheat_write),
|
||||
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
|
||||
name='Natural steam', icon='mdi:kettle-steam',
|
||||
icon='mdi:kettle-steam',
|
||||
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
|
||||
write_fn=_naturalsteam_write),
|
||||
),
|
||||
|
||||
@@ -81,7 +81,7 @@ def _burner_entities(i):
|
||||
n = i + 1
|
||||
return (
|
||||
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
|
||||
name=f'Burner {n} power level', icon='mdi:knob',
|
||||
icon='mdi:knob',
|
||||
translation_key='range_burner_power_level',
|
||||
translation_placeholders={'number': str(n)},
|
||||
options=_power_level_options,
|
||||
@@ -89,13 +89,13 @@ def _burner_entities(i):
|
||||
value_fn=_burner_field_fn(i, 'powerLevel'),
|
||||
write_fn=_burner_power_level_write(i)),
|
||||
SensorDesc(key=f'burner_{i}_state', field='burnerList',
|
||||
name=f'Burner {n} state', icon='mdi:stove',
|
||||
icon='mdi:stove',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'operationState')),
|
||||
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
|
||||
name=f'Burner {n} hot surface', device_class='heat',
|
||||
device_class='heat',
|
||||
translation_key='burner_hot_surface',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
@@ -108,7 +108,7 @@ COOKTOP_STATUS = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='cooktop_state', field='operationState',
|
||||
name='Cooktop state', icon='mdi:pot-steam'),
|
||||
icon='mdi:pot-steam'),
|
||||
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
||||
),
|
||||
)
|
||||
@@ -129,7 +129,6 @@ COOKTOP_SAFETY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
||||
name='Hot surface auto-shutoff enabled',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
||||
),
|
||||
|
||||
@@ -52,7 +52,6 @@ HOOD_ALARMS = Capability(
|
||||
SensorDesc(
|
||||
key='alarm_code',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Alarm code',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_active_alarm_codes,
|
||||
@@ -93,13 +92,11 @@ HOOD_FAN = Capability(
|
||||
FanDesc(
|
||||
key='fan',
|
||||
field='x.com.samsung.da.hood.fanSpeed',
|
||||
name=None,
|
||||
write_fn=_hood_fan_write,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='automatic_operation',
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
name='Automatic operation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
@@ -125,7 +122,6 @@ HOOD_LAMP = Capability(
|
||||
SwitchDesc(
|
||||
key='lamp',
|
||||
field='x.com.samsung.lamp.power',
|
||||
name='Lamp',
|
||||
icon='mdi:range-hood',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
write_fn=lambda payload, rep, href=None: (
|
||||
@@ -136,7 +132,6 @@ HOOD_LAMP = Capability(
|
||||
SelectDesc(
|
||||
key='lamp_brightness',
|
||||
field='x.com.samsung.lamp.current',
|
||||
name='Lamp brightness',
|
||||
icon='mdi:brightness-6',
|
||||
translation_key='range_hood_lamp_brightness',
|
||||
options_field='x.com.samsung.lamp.range',
|
||||
@@ -153,7 +148,6 @@ HOOD_FILTER = Capability(
|
||||
SensorDesc(
|
||||
key='hood_filter_usage',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
name='Filter usage',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:air-filter',
|
||||
@@ -163,7 +157,6 @@ HOOD_FILTER = Capability(
|
||||
SensorDesc(
|
||||
key='hood_filter_status',
|
||||
field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
device_class='enum',
|
||||
@@ -176,7 +169,6 @@ HOOD_FILTER = Capability(
|
||||
SensorDesc(
|
||||
key='hood_filter_capacity',
|
||||
field='x.com.samsung.da.filterCapacity',
|
||||
name='Filter capacity',
|
||||
unit='h',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic',
|
||||
@@ -194,26 +186,22 @@ AIR_QUALITY = Capability(
|
||||
SensorDesc(
|
||||
key='clean_level',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Clean level',
|
||||
icon='mdi:air-filter',
|
||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Dust',
|
||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Fine dust',
|
||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='super_fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Super fine dust',
|
||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
||||
),
|
||||
),
|
||||
@@ -227,7 +215,6 @@ AIR_LEVEL_CHECK = Capability(
|
||||
BinarySensorDesc(
|
||||
key='periodic_air_sensing',
|
||||
field='x.com.samsung.da.periodicSensingActivationState',
|
||||
name='Periodic air sensing',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
@@ -235,14 +222,12 @@ AIR_LEVEL_CHECK = Capability(
|
||||
SensorDesc(
|
||||
key='air_sensing_state',
|
||||
field='x.com.samsung.da.sensingState',
|
||||
name='Air sensing state',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_time',
|
||||
field='x.com.samsung.da.lastSensingTime',
|
||||
name='Last air sensing time',
|
||||
device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_timestamp,
|
||||
@@ -250,14 +235,12 @@ AIR_LEVEL_CHECK = Capability(
|
||||
SensorDesc(
|
||||
key='last_air_sensing_level',
|
||||
field='x.com.samsung.da.lastSensingLevel',
|
||||
name='Last air sensing level',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='automatic_ventilation_state',
|
||||
field='x.com.samsung.da.autoExeState',
|
||||
name='Automatic ventilation state',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
@@ -272,7 +255,6 @@ AUTO_VENTILATION = Capability(
|
||||
SensorDesc(
|
||||
key='auto_ventilation_action',
|
||||
field='action',
|
||||
name='Auto ventilation action',
|
||||
icon='mdi:fan-auto',
|
||||
),
|
||||
),
|
||||
|
||||
@@ -75,19 +75,19 @@ WASHER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
||||
name='Wash temperature', icon='mdi:thermometer-water',
|
||||
icon='mdi:thermometer-water',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedWaterTemperature',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
||||
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||
name='Spin speed', icon='mdi:sync',
|
||||
icon='mdi:sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedSpinLevel',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
||||
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
||||
name='Rinse cycles', icon='mdi:water-sync',
|
||||
icon='mdi:water-sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedRinseCycles',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -97,7 +97,7 @@ WASHER_SETTINGS = Capability(
|
||||
# #22. Self-gates off on plain washers, which never report
|
||||
# supportedDryLevel.
|
||||
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:tumble-dryer',
|
||||
icon='mdi:tumble-dryer',
|
||||
entity_category='config',
|
||||
translation_key='washer_dry_level',
|
||||
options_field='x.com.samsung.da.supportedDryLevel',
|
||||
@@ -260,7 +260,7 @@ def _dosing_low(prefix):
|
||||
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
||||
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
||||
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
||||
def _bool_option_switch(key, name, icon, prefix, availability_field):
|
||||
def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
def validate(p, rep, resources):
|
||||
"""Reject turning on when the selected course's byte in
|
||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
||||
@@ -286,7 +286,7 @@ def _bool_option_switch(key, name, icon, prefix, availability_field):
|
||||
return None
|
||||
|
||||
return bool_option_switch(
|
||||
key, name, icon, prefix,
|
||||
key, icon, prefix,
|
||||
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
||||
|
||||
|
||||
@@ -295,17 +295,17 @@ WASHER_COURSE = Capability(
|
||||
entities=(
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
||||
table_href='/st/washercourse/vs/0'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||
icon='mdi:washing-machine-alert', unit='cycles',
|
||||
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
|
||||
icon='mdi:washing-machine-alert',
|
||||
state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=_drum_clean_cycles_remaining),
|
||||
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
|
||||
icon='mdi:calendar-clock', device_class='timestamp',
|
||||
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
|
||||
icon='mdi:calendar-clock',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=_drum_clean_last_cleaned),
|
||||
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
|
||||
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
|
||||
translation_key='detergent_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevelCtrl'),
|
||||
@@ -313,8 +313,7 @@ WASHER_COURSE = Capability(
|
||||
_level_options('DetergentLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
|
||||
icon='mdi:water-opacity',
|
||||
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
|
||||
translation_key='detergent_water_hardness',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevel2Ctrl'),
|
||||
@@ -322,7 +321,7 @@ WASHER_COURSE = Capability(
|
||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
|
||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
|
||||
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
|
||||
translation_key='softener_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevelCtrl'),
|
||||
@@ -330,8 +329,7 @@ WASHER_COURSE = Capability(
|
||||
_level_options('SoftenerLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', name='Softener concentration',
|
||||
icon='mdi:flask-plus-outline',
|
||||
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
|
||||
translation_key='softener_concentration',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevel2Ctrl'),
|
||||
@@ -339,19 +337,19 @@ WASHER_COURSE = Capability(
|
||||
_level_options('SoftenerLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
|
||||
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
||||
BinarySensorDesc(key='detergent_low', name='Detergent low',
|
||||
icon='mdi:alert-circle-outline', device_class='problem',
|
||||
BinarySensorDesc(key='detergent_low', device_class='problem',
|
||||
icon='mdi:alert-circle-outline',
|
||||
exists_fn=bool_option_exists('DetergentAlarm'),
|
||||
rep_fn=_dosing_low('DetergentAlarm')),
|
||||
BinarySensorDesc(key='softener_low', name='Softener low',
|
||||
icon='mdi:alert-circle-outline', device_class='problem',
|
||||
BinarySensorDesc(key='softener_low', device_class='problem',
|
||||
icon='mdi:alert-circle-outline',
|
||||
exists_fn=bool_option_exists('SoftenerAlarm'),
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
|
||||
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
|
||||
'BubbleSoak', 'BubbleSoakSet'),
|
||||
_bool_option_switch('pre_wash', 'Pre-wash', 'mdi:washing-machine',
|
||||
_bool_option_switch('pre_wash', 'mdi:washing-machine',
|
||||
'PreWashSetting', 'PreWashAvailableSet'),
|
||||
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
|
||||
_bool_option_switch('intensive', 'mdi:washing-machine',
|
||||
'IntensiveSetting', 'IntensiveAvailableSet'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ class BoundEntity:
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||
_derive_name, which applies the same transform to a state key."""
|
||||
_derive_name, which applies the same transform to an href-derived key."""
|
||||
return s.replace('_', ' ').title()
|
||||
|
||||
|
||||
|
||||
@@ -27,13 +27,14 @@ def _identity(v: Any) -> Any:
|
||||
class SamsungEntityDescription:
|
||||
key: str
|
||||
field: str = ''
|
||||
name: Optional[str] = None
|
||||
# Defaults to `key`: entity names and states live in translations/, never
|
||||
# here, so a descriptor only sets this to share one catalog entry across
|
||||
# several descriptors, or to point at a differently-named one.
|
||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||
# callable form receives the coordinator's full href->rep resource
|
||||
# snapshot and returns the key to use (or None for no translation this
|
||||
# device) -- for a descriptor shared across board generations whose
|
||||
# state-code meaning isn't guaranteed consistent between them; see
|
||||
# laundry.cycle_select's table-id-gated resolver.
|
||||
# snapshot and returns the key to use -- for a descriptor shared across
|
||||
# board generations whose state-code meaning isn't guaranteed consistent
|
||||
# between them; see laundry.cycle_select's table-id-gated resolver.
|
||||
translation_placeholders: Optional[Mapping[str, str]] = None
|
||||
# Dynamic resources such as fridge compartments and ice makers use a
|
||||
# device-provided or href-derived instance label inside a translated name.
|
||||
|
||||
@@ -20,9 +20,13 @@ def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_
|
||||
return LocalThingsEntity(_FakeCoordinator(), bound)
|
||||
|
||||
|
||||
def test_explicit_fallback_name_uses_translation_instead_of_attr_name():
|
||||
"""An _attr_name would take precedence over HA's translation catalog."""
|
||||
desc = BinarySensorDesc(key='enabled', name='Explicit name')
|
||||
def test_descriptor_key_is_the_default_translation_key():
|
||||
"""A descriptor names itself through the catalog, keyed by its own key.
|
||||
|
||||
Nothing sets _attr_name: that would take precedence over HA's
|
||||
translation catalog and make the entity untranslatable.
|
||||
"""
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
entity = _make_entity(desc, instance_name='Cubed Ice')
|
||||
assert entity.translation_key == 'enabled'
|
||||
assert not hasattr(entity, '_attr_name')
|
||||
|
||||
@@ -89,7 +89,7 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
|
||||
self.writes.append(value)
|
||||
|
||||
desc = SelectDesc(
|
||||
key='mode', name='Mode', translation_key='door_alert',
|
||||
key='mode', translation_key='door_alert',
|
||||
options=('Known', 'FutureVendorMode'), write_fn=lambda *args: None,
|
||||
)
|
||||
capability = Capability(href='/x/vs/0', entities=(desc,))
|
||||
|
||||
+16
-26
@@ -125,21 +125,32 @@ def test_no_catalog_carries_unresolved_core_references():
|
||||
assert unresolved == [], language
|
||||
|
||||
|
||||
def test_every_translatable_descriptor_has_an_entity_catalog_entry():
|
||||
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
|
||||
# so it presents as the device itself, and never reads a catalog name.
|
||||
UNNAMED_DESCRIPTORS = {("fan", "fan")}
|
||||
|
||||
|
||||
def test_every_descriptor_has_an_entity_catalog_entry():
|
||||
"""A descriptor's name comes from the catalog or nowhere.
|
||||
|
||||
``translation_key`` defaults to ``desc.key`` (entity.py), and there is no
|
||||
Python-side name to fall back on, so a descriptor with no catalog entry
|
||||
is an entity with no name.
|
||||
"""
|
||||
entity_strings = _load("en")["entity"]
|
||||
missing = []
|
||||
for desc in _all_descriptions():
|
||||
platform = PLATFORM_OF[type(desc)]
|
||||
if (platform, desc.key) in UNNAMED_DESCRIPTORS:
|
||||
continue
|
||||
translation_key = desc.translation_key
|
||||
if callable(translation_key):
|
||||
# Runtime table resolvers pick their key out of the catalog
|
||||
# itself (see laundry.cycle_select), so there's nothing static
|
||||
# to check here; the generic 'cycle' fallback is asserted below.
|
||||
continue
|
||||
if translation_key is None and desc.name is not None:
|
||||
translation_key = desc.key
|
||||
if translation_key is None:
|
||||
continue # Main fan entity: device name + HA fan translations.
|
||||
platform = PLATFORM_OF[type(desc)]
|
||||
translation_key = desc.key
|
||||
if translation_key not in entity_strings.get(platform, {}):
|
||||
missing.append((platform, desc.key, translation_key))
|
||||
assert missing == []
|
||||
@@ -151,27 +162,6 @@ def test_every_translatable_descriptor_has_an_entity_catalog_entry():
|
||||
assert key in select_strings
|
||||
|
||||
|
||||
def test_descriptor_names_match_the_english_catalog():
|
||||
"""``SamsungEntityDescription.name`` never reaches the UI.
|
||||
|
||||
A descriptor carrying a ``name`` is translated under ``desc.key``
|
||||
(entity.py), so the catalog wins and the Python string survives purely
|
||||
as documentation next to the field it describes -- which is only worth
|
||||
keeping if it still says what the UI says. Editing one side alone is
|
||||
otherwise invisible.
|
||||
"""
|
||||
entity_strings = _load("en")["entity"]
|
||||
drifted = []
|
||||
for desc in _all_descriptions():
|
||||
if desc.name is None or desc.translation_key is not None:
|
||||
continue
|
||||
platform = PLATFORM_OF[type(desc)]
|
||||
catalog_name = entity_strings.get(platform, {}).get(desc.key, {}).get("name")
|
||||
if catalog_name != desc.name:
|
||||
drifted.append((platform, desc.key, desc.name, catalog_name))
|
||||
assert drifted == []
|
||||
|
||||
|
||||
def test_all_entity_state_translation_keys_are_lowercase():
|
||||
entity_strings = _load("en")["entity"]
|
||||
for platform in entity_strings.values():
|
||||
|
||||
Reference in New Issue
Block a user