diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 2e68b0d..5861bd6 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -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='_cycle'` (or similar) on the `SelectDesc`; - `options`/`options_field` supply the **raw** codes. -- Add the labels to `translations/en.json` under - `entity.select..state.`, 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..state.`, 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 diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 84501b4..e8c0b9b 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -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: diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index ec27121..5b5b736 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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')), diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 401f815..70f5e1b 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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'), ), ) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 7590328..1272a8d 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -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})), diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py index a143786..b76138f 100644 --- a/custom_components/localthings/registry/capabilities/cooktop.py +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -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, diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index 887d11d..0657c1f 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -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'), ), ) diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 9f49406..f1fcb70 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -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'), ), ) diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 6967b5b..72bf573 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -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), diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 943a18d..b8c30bd 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -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 '_On'/'_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'), ), ) diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 99d853d..9d98d96 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -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'], diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 5da0397..6fa25a0 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -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), ), diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index 3afe0c1..2ebb8af 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -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'), ), diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index e9e6869..59cebc9 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -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', ), ), diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 37d3917..a655df2 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -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'), ), ) diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 891c2ae..18cbaf9 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -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() diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 19a7ec8..903a322 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -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. diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py index fa1aa5e..d204ff4 100644 --- a/tests/test_entity_naming.py +++ b/tests/test_entity_naming.py @@ -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') diff --git a/tests/test_select_options.py b/tests/test_select_options.py index b318a95..f54ab57 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -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,)) diff --git a/tests/test_translations.py b/tests/test_translations.py index a8cb554..c360ffd 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -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():