From d0cfaeebc190fe5f13413f9d4c242c7af9bd2434 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:29:50 +0200 Subject: [PATCH 1/8] Add Dutch translations --- .../localthings/translations/nl.json | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 custom_components/localthings/translations/nl.json diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json new file mode 100644 index 0000000..abea1fc --- /dev/null +++ b/custom_components/localthings/translations/nl.json @@ -0,0 +1,302 @@ +{ + "entity": { + "select": { + "door_alert": { + "state": { + "1": "Alarm 1", + "2": "Alarm 2", + "3": "Alarm 3", + "4": "Alarm 4" + } + }, + "brightness_level": { + "state": { + "33": "Laag", + "66": "Gemiddeld", + "100": "Hoog" + } + }, + "range_hood_lamp_brightness": { + "state": { + "1": "Niveau 1", + "2": "Niveau 2" + } + }, + "ice_type": { + "state": { + "off": "Uit", + "whiskey_iceball_3": "3 ijsballen per dag", + "whiskey_iceball_6": "6 ijsballen per dag", + "whiskey_iceball_9": "9 ijsballen per dag" + } + }, + "beverage_zone_mode": { + "state": { + "sp_ttype_beer_drinks": "Dranken", + "sp_ttype_wine_dessert": "Wijn en dessert" + } + }, + "pantry_zone_mode": { + "state": { + "fdr_wine": "Wijn", + "fdr_deli": "Delicatessen", + "fdr_drinks": "Dranken" + } + }, + "flex_zone_mode": { + "state": { + "cv_ttype_rf9000a_freeze": "Vriezen", + "cv_ttype_rf9000a_softfreeze": "Zachtvriezen", + "cv_ttype_rf9000a_meat_fish": "Vlees/vis", + "cv_ttype_rf9000a_fruit_veggies": "Fruit en groenten", + "cv_ttype_rf9000a_beverage": "Dranken", + "cv_fdr_wine": "Wijn", + "cv_fdr_deli": "Delicatessen", + "cv_fdr_beverage": "Dranken", + "cv_fdr_meat": "Vlees", + "cv_fdr_soft_freezer": "Zachtvriezer" + } + }, + "dishwasher_cycle": { + "state": { + "80": "Delicaat", + "83": "Express 60", + "84": "Intensief", + "86": "Normaal", + "90": "Zelfreiniging", + "0e": "AI wassen", + "07": "Voorspoelen", + "8d": "Potten en pannen", + "8e": "Kunststof", + "8f": "Babyverzorging" + } + }, + "washer_cycle_table_02": { + "state": { + "20": "Hygiënisch stomen", + "21": "Bonte was", + "22": "Wol", + "23": "Outdoor", + "24": "Handdoeken", + "25": "Synthetisch", + "26": "Fijne was", + "27": "Spoelen+centrifugeren", + "28": "Afpompen/centrifugeren", + "29": "Trommelreiniging+", + "30": "Bewolkte dag", + "32": "Overhemden", + "33": "Beddengoed", + "36": "Wassen+drogen", + "37": "Air Wash", + "38": "Katoen drogen", + "39": "Synthetisch drogen", + "66": "Spijkergoed", + "96": "Minder microvezels", + "1c": "Eco 40-60", + "1d": "Super Speed", + "1b": "Katoen", + "1e": "Kort programma 15'", + "2f": "Sportkleding", + "2e": "Babykleding", + "2d": "Stille was", + "8f": "Intensief koud", + "1f": "Intensief koud" + } + }, + "dryer_cycle_table_03": { + "state": { + "16": "Katoen", + "18": "Synthetisch", + "19": "Fijne was", + "20": "Strijkdroog", + "23": "Snel drogen 35'", + "24": "Koude lucht", + "25": "Warme lucht", + "27": "Tijdprogramma", + "1a": "Wol", + "1b": "Beddengoed", + "1c": "Overhemden", + "1d": "Handdoeken", + "1e": "Outdoor", + "1f": "Gemengde was" + } + }, + "washer_dosing_quantity": { + "state": { + "00": "Geen", + "01": "Laag", + "02": "Gemiddeld", + "03": "Hoog" + } + }, + "washer_detergent_water_hardness": { + "state": { + "01": "Zacht", + "02": "Gemiddeld", + "03": "Hard" + } + }, + "washer_softener_concentration": { + "state": { + "01": "1x", + "02": "2x", + "03": "3x" + } + }, + "washer_dry_level": { + "state": { + "30": "30 min", + "60": "1 uur", + "90": "1 uur 30 min", + "120": "2 uur", + "180": "3 uur", + "240": "4 uur", + "none": "Uit", + "cupboard": "Kastdroog" + } + } + }, + "sensor": { + "ice_making_status": { + "state": { + "icestatus_stop": "Inactief", + "icestatus_run": "IJs maken" + } + }, + "connection_mode": { + "state": { + "observe": "Push (observeren)", + "poll": "Polling" + } + }, + "machine_state": { + "state": { + "idle": "Inactief", + "active": "Actief", + "pause": "Gepauzeerd" + } + }, + "air_filter_status": { + "state": { + "normal": "Normaal", + "wash": "Wassen", + "replace": "Vervangen" + } + } + }, + "climate": { + "airconditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Automatisch", + "low": "Laag", + "medium": "Gemiddeld", + "high": "Hoog", + "turbo": "Turbo" + } + }, + "swing_mode": { + "state": { + "off": "Vast", + "both": "Alle richtingen", + "vertical": "Omhoog en omlaag" + } + }, + "preset_mode": { + "state": { + "none": "Uit", + "sleep": "Slaap", + "quiet": "Stil", + "smart": "Slim", + "speed": "Snel" + } + } + } + } + } + }, + "config": { + "step": { + "user": { + "title": "Samsung-apparaat toevoegen", + "description": "Voer het IP-adres van je Samsung-apparaat en de AC14K_M-CA-inloggegevens in waarmee apparaatcertificaten worden aangemaakt. {reuse_note}", + "data": { + "host": "IP-adres", + "ca_cert_pem": "CA-certificaat (PEM)", + "ca_key_pem": "Privésleutel van CA (PEM)" + }, + "data_description": { + "host": "Het lokale IP-adres van je Samsung-apparaat (bijvoorbeeld 192.168.1.50).", + "ca_cert_pem": "De PEM-gecodeerde AC14K_M-CA-certificaatketen. Plak de volledige inhoud van je fullchain-PEM, inclusief alle blokken BEGIN CERTIFICATE en END CERTIFICATE.", + "ca_key_pem": "De PEM-gecodeerde AC14K_M-privésleutel. Plak de volledige inhoud, inclusief de kop- en voettekst BEGIN PRIVATE KEY en END PRIVATE KEY." + } + }, + "confirm_unknown_type": { + "title": "Apparaattype niet herkend", + "description": "Dit apparaat meldt oneUiVersion \"{one_ui_version}\", wat niet als apparaattype wordt herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen." + } + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.", + "invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.", + "unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie." + }, + "abort": { + "already_configured": "Dit apparaat is al geconfigureerd." + } + }, + "options": { + "step": { + "init": { + "title": "LocalThings-opties", + "menu_options": { + "settings": "Schrijfinstellingen voor afstandsbediening", + "debug_write": "Foutopsporing: naar een resource schrijven" + } + }, + "settings": { + "title": "Schrijfinstellingen voor afstandsbediening", + "description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.", + "data": { + "bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer de afstandsbediening als uitgeschakeld wordt gemeld" + } + }, + "debug_write": { + "title": "Foutopsporing: naar een resource schrijven", + "description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.", + "data": { + "href": "Href van resource" + } + }, + "debug_edit": { + "title": "Schrijfbewerking voor foutopsporing: {href}", + "description": "Huidige waarde van `{href}`:\n```\n{current_value}\n```\nVoer hieronder ALLEEN de velden in die je wilt wijzigen. Wat je invoert, wordt ongewijzigd naar het apparaat verzonden (als gedeeltelijke update). Het wordt NIET samengevoegd met de hierboven getoonde velden, dus plak niet de volledige waarde terug. Let op de typen: een numerieke tekenreeks zoals \"1\" moet tussen aanhalingstekens blijven staan. Een 1 zonder aanhalingstekens wordt als integer verzonden, wat sommige apparaten weigeren.", + "data": { + "payload": "Te schrijven payload" + } + }, + "debug_result": { + "title": "Resultaat van schrijfbewerking voor foutopsporing", + "description": "Het apparaat heeft CoAP-code {code} geretourneerd. Een code in de 2.xx-klasse betekent dat de schrijfbewerking is geaccepteerd; 4.xx/5.xx betekent dat deze is geweigerd. De resource heeft nu de volgende waarde:\n```\n{new_value}\n```\nAls de waarde ongewijzigd is, heeft het apparaat de schrijfbewerking waarschijnlijk genegeerd. Kies wat je hierna wilt doen:", + "menu_options": { + "debug_write": "Naar een andere resource schrijven", + "finish": "Voltooien" + } + } + }, + "error": { + "empty_payload": "Voer ten minste één veld in om te schrijven.", + "write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie." + }, + "abort": { + "not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen." + } + }, + "issues": { + "device_gap": { + "title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}", + "description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template." + } + } +} From f9ebc8e286239153c3c5b7aa3f49735686daee67 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:29 +0200 Subject: [PATCH 2/8] Make LocalThings UI fully translatable --- custom_components/localthings/__init__.py | 8 +- custom_components/localthings/config_flow.py | 35 +- custom_components/localthings/coordinator.py | 42 +- custom_components/localthings/entity.py | 52 +- .../registry/capabilities/airconditioner.py | 2 +- .../registry/capabilities/common.py | 6 +- .../registry/capabilities/cooktop.py | 2 + .../registry/capabilities/fridge.py | 18 +- .../registry/capabilities/laundry.py | 29 +- .../registry/capabilities/range.py | 6 + .../registry/capabilities/range_hood.py | 6 + .../registry/capabilities/washer.py | 12 +- .../localthings/registry/entities.py | 26 +- custom_components/localthings/select.py | 77 +- custom_components/localthings/sensor.py | 1 - custom_components/localthings/strings.json | 898 ++++++++++++++---- .../localthings/translations/en.json | 898 ++++++++++++++---- .../localthings/translations/nl.json | 824 +++++++++++++--- 18 files changed, 2354 insertions(+), 588 deletions(-) diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 1d35120..88a2ddb 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -5,7 +5,6 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, PLATFORMS from .coordinator import LocalThingsCoordinator @@ -16,10 +15,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) coordinator = LocalThingsCoordinator(hass, entry) - try: - await coordinator.async_config_entry_first_refresh() - except Exception as err: - raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err + # DataUpdateCoordinator already raises ConfigEntryNotReady with the + # underlying update context; avoid replacing it with hardcoded UI text. + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 48bdb87..f773f9d 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -316,7 +316,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _create_entry(self, info: dict) -> FlowResult: return self.async_create_entry( - title=f"Samsung Appliance ({self._host})", + title=f"Samsung ({self._host})", data={ CONF_HOST: self._host, CONF_PORT: info["port"], @@ -368,22 +368,27 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if has_creds: schema = vol.Schema({vol.Required(CONF_HOST): _TEXT}) - reuse_note = "CA credentials will be reused from the existing device." + step_id = "user_reuse" else: schema = vol.Schema({ vol.Required(CONF_HOST): _TEXT, vol.Required(CONF_CA_CERT_PEM): _MULTILINE, vol.Required(CONF_CA_KEY_PEM): _MULTILINE, }) - reuse_note = "" + step_id = "user" return self.async_show_form( - step_id="user", + step_id=step_id, data_schema=schema, errors=errors, - description_placeholders={"reuse_note": reuse_note}, ) + async def async_step_user_reuse( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the localized host-only form for additional appliances.""" + return await self.async_step_user(user_input) + async def async_step_confirm_unknown_type( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -391,14 +396,28 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self._create_entry(self._pending_info) + if not self._pending_info["one_ui_version"]: + return await self.async_step_confirm_unknown_type_no_version() + return self.async_show_form( step_id="confirm_unknown_type", data_schema=vol.Schema({}), description_placeholders={ - "one_ui_version": self._pending_info["one_ui_version"] or "(none reported)", + "one_ui_version": self._pending_info["one_ui_version"], }, ) + async def async_step_confirm_unknown_type_no_version( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm an unknown appliance that did not report oneUiVersion.""" + if user_input is not None: + return self._create_entry(self._pending_info) + return self.async_show_form( + step_id="confirm_unknown_type_no_version", + data_schema=vol.Schema({}), + ) + class LocalThingsOptionsFlow(config_entries.OptionsFlow): """Per-device options: the remote-control-off write-block override @@ -487,7 +506,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): "href": href, "current_value": ( json.dumps(current, indent=2, ensure_ascii=False) - if current else "(no cached value)" + if current else "{}" ), }, ) @@ -531,7 +550,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): "code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})", "new_value": ( json.dumps(new_rep, indent=2, ensure_ascii=False) - if new_rep else "(no value returned)" + if new_rep else "{}" ), }, ) diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 0e3d44a..7a63d04 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -24,6 +24,7 @@ from .registry.batch import parse_device0_batch from .registry.by_type import for_device, for_device_by_model, for_device_by_resources from .registry.capabilities.common import remote_control_enabled from .registry.discovery import discover, BoundEntity +from .registry.entities import ValidationIssue from .registry import CAPABILITIES from .registry.adapter import flatten from .registry.identity import read_identity, DeviceIdentity @@ -38,13 +39,6 @@ _LOGGER = logging.getLogger(__name__) _SEED_PATH = ['device', '0'] -_REMOTE_CONTROL_DISABLED_MESSAGE = ( - "Remote control is turned off on this device. Check your appliance's " - "manual for how to enable remote control before Home Assistant can " - "control it." -) - - class _NoOpDescriptor: """StateCache requires a descriptor with an on_observation hook. This integration doesn't use per-capability observation hooks, so this is a @@ -111,7 +105,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = entry.data[CONF_HOST] # placeholder until first poll self.device_info = DeviceInfo( identifiers={(DOMAIN, entry.data[CONF_HOST])}, - name=f"Samsung Appliance ({entry.data[CONF_HOST]})", + name=f"Samsung ({entry.data[CONF_HOST]})", manufacturer="Samsung", ) self._session_lock = asyncio.Lock() @@ -324,10 +318,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = serial ident = self._identity - device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance' model_num = info.get('x.com.samsung.da.modelNum', '') model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '') - name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" + # DeviceInfo has no translation mechanism. Prefer the appliance's own + # OCF name, then proper-noun/model fallbacks without an English family + # label. Existing device/entity registry IDs remain keyed by serial. + name = ( + (ident.name if ident else '') + or (f"Samsung {model}" if model else f"Samsung ({self._entry.data[CONF_HOST]})") + ) mfr = (ident.manufacturer if ident else '') or 'Samsung' self.device_info = DeviceInfo( @@ -548,12 +547,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): resources = self._cache.snapshot() bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False) if not bypass_remote_control and not remote_control_enabled(resources): - raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="remote_control_disabled", + ) validate_fn = getattr(desc, 'validate_fn', None) if validate_fn is not None: error = validate_fn(payload, rep, resources) if error: - raise ServiceValidationError(error) + if not isinstance(error, ValidationIssue): + raise TypeError("validate_fn must return ValidationIssue or None") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=error.translation_key, + translation_placeholders=dict(error.translation_placeholders), + ) result = write_fn(payload, rep, href) if result is None: self._log.warning("write_fn rejected payload %r for %s", payload, href) @@ -672,10 +680,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): help users pin down device-specific write behavior without a new release.""" if not isinstance(body, dict) or not body: - raise ServiceValidationError("Debug write payload must be a non-empty object.") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="debug_payload_empty", + ) path_segs = [s for s in href.strip('/').split('/') if s] if not path_segs: - raise ServiceValidationError("A resource href is required.") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="resource_href_required", + ) norm_href = '/' + '/'.join(path_segs) async with self._session_lock: code, new_rep = await self.hass.async_add_executor_job( diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index aeac3b0..84501b4 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -47,6 +47,19 @@ def _derive_name(state_key: str) -> str: return _snake_to_title(name).strip() +def _instance_display_name(bound: BoundEntity, state_key: str) -> str: + """Return the stable vendor/href instance label used in a name placeholder.""" + if bound.instance_name: + return bound.instance_name + source = bound.key_override or state_key + suffix = f"_{bound.desc.key}" + if source.endswith(suffix): + source = source[:-len(suffix)] + elif bound.instance and source.endswith(bound.instance): + source = source[:-len(bound.instance)] + bound.instance.replace("_", " ") + return _derive_name(source) + + class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): """Base class for all Local Things entities.""" @@ -57,15 +70,30 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): self._bound = bound self._state_key = _key(bound) self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}" - if bound.desc.name is not None: - self._attr_name = bound.desc.name - elif bound.instance_name: - # A device-given instance name (e.g. an ice maker's "Cubed - # Ice") takes the place of the href-derived instance label, - # keeping the same entity-specific suffix (issue #27). - self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip() - else: - self._attr_name = _derive_name(self._state_key) + if bound.desc.translation_placeholders is not None: + self._attr_translation_placeholders = dict( + bound.desc.translation_placeholders + ) + elif bound.desc.use_instance_name: + self._attr_translation_placeholders = { + "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) self._attr_icon = bound.desc.icon raw_cat = bound.desc.entity_category self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None @@ -88,7 +116,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): value arrives on a later poll. """ tk = self._bound.desc.translation_key - return tk(self.coordinator.last_resources) if callable(tk) else tk + 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 @property def device_info(self) -> DeviceInfo: diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 0137163..401f815 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -142,7 +142,7 @@ AIR_FILTER = Capability( SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus', name='Filter status', device_class='enum', options=('normal', 'wash', 'replace'), - translation_key='air_filter_status', + translation_key='filter_status', icon='mdi:air-filter', entity_category='diagnostic', value_fn=lambda v: v.lower() if isinstance(v, str) else v), ), diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 41adade..bcf06f3 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -280,7 +280,11 @@ WATER_FILTER = Capability( name='Filter usage', unit='%', state_class='measurement', icon='mdi:filter'), SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus', - name='Filter status', icon='mdi:filter-check'), + name='Filter status', icon='mdi:filter-check', + device_class='enum', options=('normal', 'wash', 'replace'), + value_fn=lambda value: ( + value.lower() if isinstance(value, str) else value + )), ), ) diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py index 386394c..a143786 100644 --- a/custom_components/localthings/registry/capabilities/cooktop.py +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -93,6 +93,8 @@ COOKTOP_MODE = Capability( 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', value_fn=lambda options, slot=slot: _option_value( options, f'OperationState{slot}' diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 2f2589c..2a178c5 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -55,7 +55,8 @@ TEMP_CURRENT_GENERIC = Capability( poll_tier='warm', entities=( SensorDesc(key='temperature', field='temperature', - name=None, icon='mdi:thermometer', + name=None, translation_key='instance_temperature', + use_instance_name=True, icon='mdi:thermometer', device_class='temperature', unit_fn=_temp_unit, state_class='measurement'), ), @@ -68,7 +69,8 @@ TEMP_SETPOINT_GENERIC = Capability( poll_tier='warm', entities=( NumberDesc(key='setpoint', field='temperature', - name=None, device_class='temperature', unit_fn=_temp_unit, + name=None, 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', write_fn=lambda p, rep, href=None: ( @@ -123,17 +125,18 @@ ICEMAKER_GENERIC = Capability( entities=( SensorDesc(key='making_status', field='x.com.samsung.da.iceMaker.iceMakingStatus', - name=None, icon='mdi:cube-outline', + name=None, 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, icon='mdi:cube-outline', + name=None, 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, icon='mdi:cube-outline', + name=None, use_instance_name=True, icon='mdi:cube-outline', translation_key='ice_type', entity_category='config', options_field='x.com.samsung.da.iceType.supported', @@ -387,7 +390,7 @@ CABINET_LIGHT_ENHANCED = Capability( entities=( SelectDesc(key='day_brightness', field='level.brightness.daytime', name='Cabinet brightness', icon='mdi:brightness-5', - translation_key='brightness_level', + translation_key='day_brightness', entity_category='config', options=_NIGHT_BRIGHTNESS_OPTIONS, write_fn=lambda p, rep, href=None: ( @@ -575,7 +578,8 @@ DOOR_GENERIC = Capability( poll_tier='hot', entities=( BinarySensorDesc(key='open', field='openState', - name=None, device_class='door', + name=None, translation_key='instance_open', + use_instance_name=True, device_class='door', value_fn=lambda v: v == 'Open'), ), ) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 76fca4a..afec8e3 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -276,6 +276,12 @@ def _table_id(resources, table_href): return rep.get('x.com.samsung.da.st.courseTable') +_TRANSLATED_COURSE_TABLES = { + 'dryer_cycle': frozenset({'table_03'}), + 'washer_cycle': frozenset({'table_02'}), +} + + def cycle_select(*, translation_key, icon, table_href=None): """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. @@ -287,23 +293,19 @@ def cycle_select(*, translation_key, icon, table_href=None): suffixes translation_key with the device's own course-table id, read from /st/washercourse/vs/0 or /st/dryercourse/vs/0's x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' -> - 'washer_cycle_table_02'). No table id available at all -- the href - absent or empty -- gets no translation_key, i.e. the raw course code - displayed as-is. + 'washer_cycle_table_02'). An absent or unrecognized table id gets the + name-only ``cycle`` translation key while the raw course code remains + visible and writable. This matters because course codes are NOT guaranteed consistent across board generations sharing the same /course/vs/0 contract: every code in washer_cycle_table_02 was confirmed against Table_02-reporting devices (DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports Table_00 instead, so the same hex code could mean a different course - there for all we've verified. Building the key from whatever table the - device actually reports, rather than gating a single hardcoded key on - an exact match, means a table we haven't built translations for yet - (like Table_00) just falls through Home Assistant's own missing- - translation handling to the same raw-code display -- exactly what - happens today for any individual code within a table's translations - that isn't populated yet -- and adding one later needs new strings.json - entries, not a code change here. + there for all we've verified. Only tables with a complete catalog are + assigned a state translation key. Unknown future tables deliberately use + the generic key, avoiding misleading translations and missing-translation + errors while keeping the exact vendor code for writes. Left at its default for dishwasher, which has no equivalent table-id resource in any dump seen and no evidence its course codes vary by @@ -314,7 +316,10 @@ def cycle_select(*, translation_key, icon, table_href=None): if table_href is not None: def key(resources): table = _table_id(resources, table_href) - return f'{translation_key}_{table.lower()}' if table else None + table_id = table.lower() if isinstance(table, str) else None + if table_id in _TRANSLATED_COURSE_TABLES.get(translation_key, ()): + return f'{translation_key}_{table_id}' + return 'cycle' return SelectDesc( key='cycle', name='Cycle', icon=icon, translation_key=key, diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index 7c1e195..3afe0c1 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -82,16 +82,22 @@ def _burner_entities(i): return ( SelectDesc(key=f'burner_{i}_power_level', field='burnerList', name=f'Burner {n} power level', icon='mdi:knob', + translation_key='range_burner_power_level', + translation_placeholders={'number': str(n)}, options=_power_level_options, exists_fn=exists, 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', + 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', + translation_key='burner_hot_surface', + translation_placeholders={'number': str(n)}, exists_fn=exists, value_fn=_burner_hot_surface_fn(i)), ) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 48e2873..e9e6869 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -166,6 +166,12 @@ HOOD_FILTER = Capability( name='Filter status', icon='mdi:air-filter', entity_category='diagnostic', + device_class='enum', + options=('normal', 'wash', 'replace'), + translation_key='filter_status', + value_fn=lambda value: ( + value.lower() if isinstance(value, str) else value + ), ), SensorDesc( key='hood_filter_capacity', diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 8c21137..54ffaaa 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,7 +16,7 @@ array. from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, ValidationIssue from .laundry import ( bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value, replace_in_options, @@ -282,7 +282,7 @@ def _bool_option_switch(key, name, icon, prefix, availability_field): if len(pairs) != len(courses): return None if pairs[courses.index(current)] != 'F0': - return f"{name} isn't available on the selected cycle." + return ValidationIssue(f"{key}_unavailable_for_cycle") return None return bool_option_switch( @@ -306,7 +306,7 @@ WASHER_COURSE = Capability( 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', - translation_key='washer_dosing_quantity', + translation_key='detergent_quantity', entity_category='config', options=_level_options('DetergentLevelCtrl'), exists_fn=lambda rep, resources: bool( @@ -315,7 +315,7 @@ WASHER_COURSE = Capability( write_fn=_level_write('DetergentLevelCtrl')), SelectDesc(key='detergent_water_hardness', name='Detergent water hardness', icon='mdi:water-opacity', - translation_key='washer_detergent_water_hardness', + translation_key='detergent_water_hardness', entity_category='config', options=_level_options('DetergentLevel2Ctrl'), exists_fn=lambda rep, resources: bool( @@ -323,7 +323,7 @@ WASHER_COURSE = Capability( rep_fn=_dosing_level('DetergentLevel2Ctrl'), write_fn=_level_write('DetergentLevel2Ctrl')), SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline', - translation_key='washer_dosing_quantity', + translation_key='softener_quantity', entity_category='config', options=_level_options('SoftenerLevelCtrl'), exists_fn=lambda rep, resources: bool( @@ -332,7 +332,7 @@ WASHER_COURSE = Capability( write_fn=_level_write('SoftenerLevelCtrl')), SelectDesc(key='softener_concentration', name='Softener concentration', icon='mdi:flask-plus-outline', - translation_key='washer_softener_concentration', + translation_key='softener_concentration', entity_category='config', options=_level_options('SoftenerLevel2Ctrl'), exists_fn=lambda rep, resources: bool( diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 5282500..1325e1d 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -9,14 +9,24 @@ where a description declares one. from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Callable, Optional +from typing import Any, Callable, Mapping, Optional WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] -# (payload, rep, resources) -> a human-readable rejection message, or None to -# allow the write. resources is the coordinator's full href->rep snapshot, for -# the same cross-resource lookups exists_fn needs (e.g. reading a sibling -# href's live option list). -ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] +# (payload, rep, resources) -> a translatable rejection, or None to allow the +# write. resources is the coordinator's full href->rep snapshot, for the same +# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live +# option list). + + +@dataclass(frozen=True) +class ValidationIssue: + """A user-facing, translatable rejection from a descriptor validator.""" + + translation_key: str + translation_placeholders: Mapping[str, str] = field(default_factory=dict) + + +ValidateFn = Optional[Callable[[Any, dict, dict], "ValidationIssue | None"]] def _identity(v: Any) -> Any: @@ -34,6 +44,10 @@ class SamsungEntityDescription: # 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. + 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. + use_instance_name: bool = False icon: Optional[str] = None entity_category: Optional[str] = None # 'diagnostic' | 'config' | None enabled_default: bool = True diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 2d84b9e..c85cc95 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -31,6 +31,79 @@ async def async_setup_entry( _CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])') +# Keep this synchronized with entity.select.*.state in strings.json. A select +# can have a translation key for its name without translating its dynamic +# Samsung options. Only known states are normalized for HA's lowercase lookup; +# unknown future vendor values retain a readable label and exact write value. +TRANSLATED_SELECT_STATES: dict[str, frozenset[str]] = { + 'beverage_zone_mode': frozenset({ + 'sp_ttype_beer_drinks', 'sp_ttype_wine_dessert', + }), + 'brightness_level': frozenset({'33', '66', '100'}), + 'day_brightness': frozenset({'33', '66', '100'}), + 'dishwasher_cycle': frozenset({ + '07', '0e', '80', '83', '84', '86', '8d', '8e', '8f', '90', + }), + 'door_alert': frozenset({'1', '2', '3', '4'}), + 'dryer_cycle_table_03': frozenset({ + '16', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', '20', + '23', '24', '25', '27', + }), + 'finish_sound': frozenset({'off', 'on'}), + 'flex_zone_mode': frozenset({ + 'cv_fdr_beverage', 'cv_fdr_deli', 'cv_fdr_meat', + 'cv_fdr_soft_freezer', 'cv_fdr_wine', + 'cv_ttype_rf9000a_beverage', 'cv_ttype_rf9000a_freeze', + 'cv_ttype_rf9000a_fruit_veggies', + 'cv_ttype_rf9000a_meat_fish', 'cv_ttype_rf9000a_softfreeze', + }), + 'heated_dry': frozenset({'extra_high', 'high', 'low', 'off'}), + 'ice_type': frozenset({ + 'off', 'whiskey_iceball_3', 'whiskey_iceball_6', + 'whiskey_iceball_9', + }), + 'led_brightness': frozenset({'high', 'low'}), + 'led_night_brightness': frozenset({'high', 'low'}), + 'oven_mode': frozenset({ + 'air_fry', 'bake', 'broil', 'convection', 'convection_bake', + 'convection_broil', 'frozen_pizza_plus', 'no_operation', + 'plate_warm', 'slow_cook', + }), + 'pantry_zone_mode': frozenset({'fdr_deli', 'fdr_drinks', 'fdr_wine'}), + 'range_burner_power_level': frozenset({'0', 'boost', 'simmer'}), + 'sound_mode': frozenset({'mute', 'tone', 'voice'}), + 'spin_speed': frozenset({'high', 'low', 'medium', 'no_spin', 'rinse_hold'}), + 'buzzer_sound': frozenset({'off', 'on'}), + 'wash_temperature': frozenset({ + 'cold', 'cool', 'extra_hot', 'hot', 'none', 'warm', + }), + 'washer_cycle_table_02': frozenset({ + '1b', '1c', '1d', '1e', '1f', '20', '21', '22', '23', '24', + '25', '26', '27', '28', '29', '2d', '2e', '2f', '30', '32', + '33', '36', '37', '38', '39', '66', '8f', '96', + }), + 'detergent_quantity': frozenset({'00', '01', '02', '03'}), + 'softener_quantity': frozenset({'00', '01', '02', '03'}), + 'detergent_water_hardness': frozenset({'01', '02', '03'}), + 'softener_concentration': frozenset({'01', '02', '03'}), + 'washer_dry_level': frozenset({ + '30', '60', '90', '120', '180', '240', 'cupboard', 'none', + }), + 'range_hood_lamp_brightness': frozenset({'1', '2'}), +} + + +def _translation_state(value: str, translation_key: str) -> str | None: + """Return a known HA translation state, or None for a vendor fallback.""" + known = TRANSLATED_SELECT_STATES.get(translation_key) + if known is None: + return None + direct = value.lower().replace(' ', '_') + if direct in known: + return direct + snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_') + return snake if snake in known else None + def _display(value, translation_key: Optional[str]): """Turn a raw device option/state value into what's shown in the UI. @@ -56,8 +129,8 @@ def _display(value, translation_key: Optional[str]): """ if not isinstance(value, str): return value - if translation_key: - return value.lower() + if translation_key and (translated := _translation_state(value, translation_key)): + return translated if value.islower(): return value.replace('_', ' ').title() return _CAMEL_BOUNDARY_RE.sub(' ', value) diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index 75f575a..bb1ec74 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -61,7 +61,6 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], Disabled by default — it's for troubleshooting, not everyday use.""" _attr_has_entity_name = True - _attr_name = 'Connection mode' _attr_translation_key = 'connection_mode' _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index de1ac17..412cec0 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -1,187 +1,88 @@ { "entity": { - "select": { - "door_alert": { - "state": { - "1": "Alarm 1", - "2": "Alarm 2", - "3": "Alarm 3", - "4": "Alarm 4" - } + "binary_sensor": { + "any_burner_active": { + "name": "Any burner active" }, - "brightness_level": { - "state": { - "33": "Low", - "66": "Medium", - "100": "High" - } + "automatic_operation": { + "name": "Automatic operation" }, - "range_hood_lamp_brightness": { - "state": { - "1": "Level 1", - "2": "Level 2" - } + "burner_hot_surface": { + "name": "Burner {number} hot surface" }, - "ice_type": { - "state": { - "off": "Off", - "whiskey_iceball_3": "3 Balls/Day", - "whiskey_iceball_6": "6 Balls/Day", - "whiskey_iceball_9": "9 Balls/Day" - } + "cloud_connected": { + "name": "Cloud connected" }, - "beverage_zone_mode": { - "state": { - "sp_ttype_beer_drinks": "Beverage", - "sp_ttype_wine_dessert": "Wine and Dessert" - } + "cooktop_safety_shutoff_enabled": { + "name": "Hot surface auto-shutoff enabled" }, - "pantry_zone_mode": { - "state": { - "fdr_wine": "Wine", - "fdr_deli": "Deli", - "fdr_drinks": "Drinks" - } + "current_limit_enabled": { + "name": "Current limit enabled" }, - "flex_zone_mode": { - "state": { - "cv_ttype_rf9000a_freeze": "Freeze", - "cv_ttype_rf9000a_softfreeze": "Soft Freeze", - "cv_ttype_rf9000a_meat_fish": "Meat/Fish", - "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", - "cv_ttype_rf9000a_beverage": "Beverage", - "cv_fdr_wine": "Wine", - "cv_fdr_deli": "Deli", - "cv_fdr_beverage": "Beverage", - "cv_fdr_meat": "Meat", - "cv_fdr_soft_freezer": "Soft Freezer" - } + "cycle_active": { + "name": "[%key:component::binary_sensor::entity_component::running::name%]" }, - "dishwasher_cycle": { - "state": { - "0e": "AI Wash", - "07": "Pre blast", - "90": "Self clean", - "86": "Normal", - "83": "Express 60", - "84": "Heavy", - "8d": "Pots and pans", - "80": "Delicate", - "8e": "Plastic", - "8f": "Baby Care" - } + "defrost_active": { + "name": "Defrost active" }, - "washer_cycle_table_02": { - "state": { - "1c": "Eco 40-60", - "1d": "Super Speed", - "21": "Colours", - "1b": "Cotton", - "1e": "15' Quick Wash", - "29": "Drum Clean+", - "24": "Towels", - "33": "Bedding", - "28": "Drain/Spin", - "26": "Delicates", - "27": "Rinse+Spin", - "22": "Wool", - "20": "Hygiene Steam", - "23": "Outdoor", - "25": "Synthetics", - "32": "Shirts", - "2f": "Activewear", - "2e": "Baby Care", - "30": "Cloudy Day", - "66": "Denim", - "2d": "Silent Wash", - "8f": "Intense Cold", - "96": "Less Microfiber", - "36": "Wash+Dry", - "37": "Air Wash", - "38": "Cotton Dry", - "39": "Synthetics Dry", - "1f": "Intense Cold" - } + "detergent_low": { + "name": "Detergent low" }, - "dryer_cycle_table_03": { - "state": { - "16": "Cotton", - "18": "Synthetics", - "19": "Delicates", - "1a": "Wool", - "1b": "Bedding", - "1c": "Shirts", - "1d": "Towels", - "1e": "Outdoor", - "1f": "Mixed Load", - "20": "Iron Dry", - "23": "Quick Dry 35", - "24": "Cool Air", - "25": "Warm Air", - "27": "Time Dry" - } + "device_active": { + "name": "Device active" }, - "washer_dosing_quantity": { - "state": { - "00": "None", - "01": "Low", - "02": "Medium", - "03": "High" - } + "door_open": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" }, - "washer_detergent_water_hardness": { - "state": { - "01": "Soft", - "02": "Medium", - "03": "Hard" - } + "firmware_update": { + "name": "Firmware update available" }, - "washer_softener_concentration": { - "state": { - "01": "1x", - "02": "2x", - "03": "3x" - } + "instance_open": { + "name": "{instance_name} open" }, - "washer_dry_level": { - "state": { - "none": "Off", - "cupboard": "Cupboard", - "30": "30 min", - "60": "1 hr", - "90": "1 hr 30", - "120": "2 hr", - "180": "3 hr", - "240": "4 hr" - } + "paired_hood_connected": { + "name": "Paired hood connected" + }, + "paired_hood_light": { + "name": "Paired hood light" + }, + "paired_hood_power": { + "name": "Paired hood power" + }, + "periodic_air_sensing": { + "name": "Periodic air sensing" + }, + "power_state": { + "name": "Power state" + }, + "rapid_freezing": { + "name": "Rapid freezing" + }, + "rapid_fridge": { + "name": "Rapid fridge" + }, + "remote_control": { + "name": "Smart Control" + }, + "softener_low": { + "name": "Softener low" } }, - "sensor": { - "ice_making_status": { - "state": { - "icestatus_stop": "Idle", - "icestatus_run": "Making ice" - } + "button": { + "diagnosis_start": { + "name": "Start diagnosis" }, - "connection_mode": { - "state": { - "observe": "Push (observe)", - "poll": "Polling" - } + "pause": { + "name": "[%key:common::action::pause%]" }, - "machine_state": { - "state": { - "idle": "Idle", - "active": "Active", - "pause": "Paused" - } + "selfcheck_start": { + "name": "Start self-check" }, - "air_filter_status": { - "state": { - "normal": "Normal", - "wash": "Wash", - "replace": "Replace" - } + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" } }, "climate": { @@ -189,24 +90,11 @@ "state_attributes": { "fan_mode": { "state": { - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", "turbo": "Turbo" } }, - "swing_mode": { - "state": { - "off": "Fixed", - "both": "All directions", - "vertical": "Up and down" - } - }, "preset_mode": { "state": { - "none": "Off", - "sleep": "Sleep", "quiet": "Quiet", "smart": "Smart", "speed": "Speed" @@ -214,17 +102,625 @@ } } } + }, + "number": { + "cook_time": { + "name": "Cook time" + }, + "delay_start_hours": { + "name": "Delay start" + }, + "instance_setpoint": { + "name": "{instance_name} setpoint" + }, + "oven_setpoint": { + "name": "Setpoint" + }, + "sound_volume": { + "name": "Sound volume" + } + }, + "select": { + "ai_energy_level": { + "name": "AI Energy Mode level" + }, + "beverage_zone_mode": { + "name": "Beverage zone mode", + "state": { + "sp_ttype_beer_drinks": "Beverage", + "sp_ttype_wine_dessert": "Wine and dessert" + } + }, + "brightness_level": { + "name": "Night brightness", + "state": { + "33": "[%key:common::state::low%]", + "66": "[%key:common::state::medium%]", + "100": "[%key:common::state::high%]" + } + }, + "day_brightness": { + "name": "Cabinet brightness", + "state": { + "33": "[%key:common::state::low%]", + "66": "[%key:common::state::medium%]", + "100": "[%key:common::state::high%]" + } + }, + "buzzer_sound": { + "name": "Buzzer sound", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "cycle": { + "name": "Cycle" + }, + "detergent_quantity": { + "name": "Detergent quantity", + "state": { + "00": "None", + "01": "[%key:common::state::low%]", + "02": "[%key:common::state::medium%]", + "03": "[%key:common::state::high%]" + } + }, + "detergent_water_hardness": { + "name": "Detergent water hardness", + "state": { + "01": "Soft", + "02": "[%key:common::state::medium%]", + "03": "Hard" + } + }, + "dishwasher_cycle": { + "name": "Cycle", + "state": { + "80": "Delicate", + "83": "Express 60", + "84": "Heavy", + "86": "Normal", + "90": "Self clean", + "0e": "AI Wash", + "07": "Pre blast", + "8d": "Pots and pans", + "8e": "Plastic", + "8f": "Baby Care" + } + }, + "door_alert": { + "name": "Door alarm", + "state": { + "1": "Alarm 1", + "2": "Alarm 2", + "3": "Alarm 3", + "4": "Alarm 4" + } + }, + "dryer_cycle_table_03": { + "name": "Cycle", + "state": { + "16": "Cotton", + "18": "Synthetics", + "19": "Delicates", + "20": "Iron dry", + "23": "Quick Dry 35", + "24": "Cool air", + "25": "Warm air", + "27": "Time dry", + "1a": "Wool", + "1b": "Bedding", + "1c": "Shirts", + "1d": "Towels", + "1e": "Outdoor", + "1f": "Mixed load" + } + }, + "finish_sound": { + "name": "Finish sound", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "flex_zone_mode": { + "name": "Flex zone mode", + "state": { + "cv_ttype_rf9000a_freeze": "Freeze", + "cv_ttype_rf9000a_softfreeze": "Soft freeze", + "cv_ttype_rf9000a_meat_fish": "Meat/Fish", + "cv_ttype_rf9000a_fruit_veggies": "Fruit & vegetables", + "cv_ttype_rf9000a_beverage": "Beverage", + "cv_fdr_wine": "Wine", + "cv_fdr_deli": "Deli", + "cv_fdr_beverage": "Beverage", + "cv_fdr_meat": "Meat", + "cv_fdr_soft_freezer": "Soft freezer" + } + }, + "heated_dry": { + "name": "Smart Dry", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]", + "extra_high": "Extra high" + } + }, + "ice_type": { + "name": "{instance_name} type", + "state": { + "off": "[%key:common::state::off%]", + "whiskey_iceball_3": "3 balls/day", + "whiskey_iceball_6": "6 balls/day", + "whiskey_iceball_9": "9 balls/day" + } + }, + "led_brightness": { + "name": "Door LED brightness", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "led_night_brightness": { + "name": "Door LED night brightness", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "oven_mode": { + "name": "Cooking mode", + "state": { + "no_operation": "No operation", + "bake": "Bake", + "broil": "Broil", + "convection": "Convection", + "convection_bake": "Convection bake", + "convection_broil": "Convection broil", + "frozen_pizza_plus": "Frozen Pizza+", + "slow_cook": "Slow cook", + "plate_warm": "Plate warm", + "air_fry": "Air fry" + } + }, + "pantry_zone_mode": { + "name": "Pantry zone mode", + "state": { + "fdr_wine": "Wine", + "fdr_deli": "Deli", + "fdr_drinks": "Drinks" + } + }, + "range_burner_power_level": { + "name": "Burner {number} power level", + "state": { + "0": "[%key:common::state::off%]", + "boost": "Boost", + "simmer": "Simmer" + } + }, + "range_hood_lamp_brightness": { + "name": "Lamp brightness", + "state": { + "1": "Level 1", + "2": "Level 2" + } + }, + "rinse_cycles": { + "name": "Rinse cycles" + }, + "softener_concentration": { + "name": "Softener concentration", + "state": { + "01": "1x", + "02": "2x", + "03": "3x" + } + }, + "softener_quantity": { + "name": "Softener quantity", + "state": { + "00": "None", + "01": "[%key:common::state::low%]", + "02": "[%key:common::state::medium%]", + "03": "[%key:common::state::high%]" + } + }, + "sound_mode": { + "name": "Sound mode", + "state": { + "voice": "Voice", + "tone": "Tone", + "mute": "Mute" + } + }, + "spin_speed": { + "name": "Spin speed", + "state": { + "rinse_hold": "Rinse hold", + "no_spin": "No spin", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } + }, + "wash_temperature": { + "name": "Wash temperature", + "state": { + "none": "None", + "cold": "Cold", + "cool": "Cool", + "warm": "Warm", + "hot": "Hot", + "extra_hot": "Extra hot" + } + }, + "washer_cycle_table_02": { + "name": "Cycle", + "state": { + "20": "Hygiene Steam", + "21": "Colours", + "22": "Wool", + "23": "Outdoor", + "24": "Towels", + "25": "Synthetics", + "26": "Delicates", + "27": "Rinse+Spin", + "28": "Drain/Spin", + "29": "Drum Clean+", + "30": "Cloudy Day", + "32": "Shirts", + "33": "Bedding", + "36": "Wash+Dry", + "37": "Air Wash", + "38": "Cotton Dry", + "39": "Synthetics Dry", + "66": "Denim", + "96": "Less Microfiber", + "1c": "Eco 40-60", + "1d": "Super Speed", + "1b": "Cotton", + "1e": "15' Quick Wash", + "2f": "Activewear", + "2e": "Baby Care", + "2d": "Silent Wash", + "8f": "Intense Cold", + "1f": "Intense Cold" + } + }, + "washer_dry_level": { + "name": "Dry level", + "state": { + "30": "30 min", + "60": "1 hr", + "90": "1 hr 30", + "120": "2 hr", + "180": "3 hr", + "240": "4 hr", + "none": "[%key:common::state::off%]", + "cupboard": "Cupboard" + } + } + }, + "sensor": { + "air_filter_usage": { + "name": "Filter usage" + }, + "air_sensing_state": { + "name": "Air sensing state" + }, + "alarm_code": { + "name": "Alarm code" + }, + "auto_ventilation_action": { + "name": "Auto ventilation action" + }, + "automatic_ventilation_state": { + "name": "Automatic ventilation state" + }, + "burner_state": { + "name": "Burner {number} state" + }, + "clean_level": { + "name": "Clean level" + }, + "connection_mode": { + "name": "Connection mode", + "state": { + "observe": "Push (observe)", + "poll": "Polling" + } + }, + "cooktop_state": { + "name": "Cooktop state" + }, + "current_limit_level": { + "name": "Current limit level" + }, + "current_temp_c": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "diagnosis": { + "name": "Diagnosis" + }, + "diagnosis_status": { + "name": "Diagnosis status" + }, + "drum_clean_cycles_remaining": { + "name": "Drum clean due in" + }, + "drum_clean_last_cleaned": { + "name": "Drum last cleaned" + }, + "dry_level": { + "name": "Dry level" + }, + "dry_time": { + "name": "Dry time" + }, + "dryer_type": { + "name": "Dryer type" + }, + "dust": { + "name": "Dust" + }, + "energy_kwh": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "energy_last_month_kwh": { + "name": "Energy (last month)" + }, + "energy_saved_kwh": { + "name": "Energy saved" + }, + "energy_this_month_kwh": { + "name": "Energy (this month)" + }, + "fan_direction": { + "name": "Fan direction" + }, + "fan_speed_level": { + "name": "Fan speed level" + }, + "filter_progress": { + "name": "Filter progress" + }, + "fine_dust": { + "name": "Fine dust" + }, + "finish_time": { + "name": "Estimated finish" + }, + "freezer_temperature": { + "name": "Freezer temperature" + }, + "fridge_temperature": { + "name": "Fridge temperature" + }, + "hood_filter_capacity": { + "name": "Filter capacity" + }, + "hood_filter_usage": { + "name": "Filter usage" + }, + "instance_temperature": { + "name": "{instance_name} temperature" + }, + "job_beginning_status": { + "name": "Job beginning status" + }, + "last_air_sensing_level": { + "name": "Last air sensing level" + }, + "last_air_sensing_time": { + "name": "Last air sensing time" + }, + "main_timer_current": { + "name": "Timer current value" + }, + "main_timer_state": { + "name": "Timer state" + }, + "odor": { + "name": "Odor" + }, + "operating_mode": { + "name": "Operating mode" + }, + "operation_origin": { + "name": "Last operation source" + }, + "operation_time_minutes": { + "name": "Operation time" + }, + "oven_state": { + "name": "Cavity state" + }, + "paired_hood_fan_speed": { + "name": "Paired hood fan speed" + }, + "paired_hood_firmware": { + "name": "Paired hood firmware" + }, + "paired_hood_model": { + "name": "Paired hood model" + }, + "power_energy_kwh": { + "name": "Power energy" + }, + "power_watts": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "progress": { + "name": "Progress" + }, + "progress_percentage": { + "name": "Progress percent" + }, + "selfcheck_error": { + "name": "Self-check error" + }, + "selfcheck_result": { + "name": "Self-check result" + }, + "selfcheck_status": { + "name": "Self-check status" + }, + "super_fine_dust": { + "name": "Super fine dust" + }, + "water_liters": { + "name": "Water consumption" + }, + "machine_state": { + "name": "Machine state", + "state": { + "idle": "[%key:common::state::idle%]", + "active": "[%key:common::state::active%]", + "pause": "Paused" + } + }, + "filter_status": { + "name": "Filter status", + "state": { + "normal": "[%key:common::state::normal%]", + "wash": "Wash", + "replace": "Replace" + } + }, + "ice_making_status": { + "name": "{instance_name} making status", + "state": { + "icestatus_stop": "[%key:common::state::idle%]", + "icestatus_run": "Making ice" + } + } + }, + "switch": { + "ai_energy_level": { + "name": "AI Energy Mode" + }, + "air_purify": { + "name": "Air purification" + }, + "auto_clean": { + "name": "Auto clean" + }, + "auto_door_opener": { + "name": "Auto door opener" + }, + "auto_release_dry": { + "name": "Auto Release Dry" + }, + "autofill": { + "name": "Autofill pitcher" + }, + "bubble_soak": { + "name": "Bubble Soak" + }, + "cabinet_light_dim": { + "name": "Brighten gradually" + }, + "cabinet_light_switch": { + "name": "Cabinet light" + }, + "child_lock": { + "name": "Child lock" + }, + "defrost_delay": { + "name": "Defrost delay" + }, + "display_light": { + "name": "Display light" + }, + "fast_preheat": { + "name": "Fast preheat" + }, + "fridge_sound": { + "name": "Sound" + }, + "ice_maker_enabled": { + "name": "Ice maker" + }, + "ice_night_mode": { + "name": "Nighttime ice quiet mode" + }, + "instance_enabled": { + "name": "{instance_name} enabled" + }, + "intensive": { + "name": "Intensive" + }, + "lamp": { + "name": "Lamp" + }, + "led_night_light": { + "name": "Door LED night light" + }, + "mute_once": { + "name": "Mute once" + }, + "natural_steam": { + "name": "Natural steam" + }, + "power_switch": { + "name": "Power" + }, + "pre_wash": { + "name": "Pre-wash" + }, + "rapid_freezing": { + "name": "Rapid freezing" + }, + "rapid_fridge": { + "name": "Rapid fridge" + }, + "sabbath_mode": { + "name": "Sabbath mode" + }, + "sanitize": { + "name": "Sanitize" + }, + "sound": { + "name": "Sound" + }, + "storm_wash": { + "name": "Storm Wash+" + }, + "welcome_lighting": { + "name": "Welcome lighting" + }, + "wrinkle_prevent": { + "name": "Wrinkle prevent" + } + }, + "time": { + "led_night_end": { + "name": "Door LED night end" + }, + "led_night_start": { + "name": "Door LED night start" + }, + "night_end": { + "name": "Night light end" + }, + "night_start": { + "name": "Night light start" + } } }, "config": { "step": { "user": { - "title": "Add Samsung Appliance", - "description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}", + "title": "Add Samsung appliance", + "description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates.", "data": { - "host": "IP Address", - "ca_cert_pem": "CA Certificate (PEM)", - "ca_key_pem": "CA Private Key (PEM)" + "host": "IP address", + "ca_cert_pem": "CA certificate (PEM)", + "ca_key_pem": "CA private key (PEM)" }, "data_description": { "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).", @@ -232,9 +728,23 @@ "ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer." } }, + "user_reuse": { + "title": "Add Samsung appliance", + "description": "Enter the IP address of your Samsung appliance. The CA credentials from an existing LocalThings appliance will be reused.", + "data": { + "host": "IP address" + }, + "data_description": { + "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50)." + } + }, "confirm_unknown_type": { "title": "Appliance type not recognized", "description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." + }, + "confirm_unknown_type_no_version": { + "title": "Appliance type not recognized", + "description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." } }, "error": { @@ -243,13 +753,13 @@ "unknown": "Unexpected error. Check the Home Assistant logs for details." }, "abort": { - "already_configured": "This device is already configured." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { "step": { "init": { - "title": "Local Things Options", + "title": "LocalThings options", "menu_options": { "settings": "Remote control write settings", "debug_write": "Debug: write to a resource" @@ -298,5 +808,25 @@ "title": "Incomplete capability coverage for {device_name}", "description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template." } + }, + "exceptions": { + "remote_control_disabled": { + "message": "Remote control is turned off on this device. Check your appliance's manual for how to enable remote control before Home Assistant can control it." + }, + "debug_payload_empty": { + "message": "Debug write payload must be a non-empty object." + }, + "resource_href_required": { + "message": "A resource href is required." + }, + "bubble_soak_unavailable_for_cycle": { + "message": "Bubble Soak isn't available on the selected cycle." + }, + "pre_wash_unavailable_for_cycle": { + "message": "Pre-wash isn't available on the selected cycle." + }, + "intensive_unavailable_for_cycle": { + "message": "Intensive isn't available on the selected cycle." + } } } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index de1ac17..412cec0 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1,187 +1,88 @@ { "entity": { - "select": { - "door_alert": { - "state": { - "1": "Alarm 1", - "2": "Alarm 2", - "3": "Alarm 3", - "4": "Alarm 4" - } + "binary_sensor": { + "any_burner_active": { + "name": "Any burner active" }, - "brightness_level": { - "state": { - "33": "Low", - "66": "Medium", - "100": "High" - } + "automatic_operation": { + "name": "Automatic operation" }, - "range_hood_lamp_brightness": { - "state": { - "1": "Level 1", - "2": "Level 2" - } + "burner_hot_surface": { + "name": "Burner {number} hot surface" }, - "ice_type": { - "state": { - "off": "Off", - "whiskey_iceball_3": "3 Balls/Day", - "whiskey_iceball_6": "6 Balls/Day", - "whiskey_iceball_9": "9 Balls/Day" - } + "cloud_connected": { + "name": "Cloud connected" }, - "beverage_zone_mode": { - "state": { - "sp_ttype_beer_drinks": "Beverage", - "sp_ttype_wine_dessert": "Wine and Dessert" - } + "cooktop_safety_shutoff_enabled": { + "name": "Hot surface auto-shutoff enabled" }, - "pantry_zone_mode": { - "state": { - "fdr_wine": "Wine", - "fdr_deli": "Deli", - "fdr_drinks": "Drinks" - } + "current_limit_enabled": { + "name": "Current limit enabled" }, - "flex_zone_mode": { - "state": { - "cv_ttype_rf9000a_freeze": "Freeze", - "cv_ttype_rf9000a_softfreeze": "Soft Freeze", - "cv_ttype_rf9000a_meat_fish": "Meat/Fish", - "cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies", - "cv_ttype_rf9000a_beverage": "Beverage", - "cv_fdr_wine": "Wine", - "cv_fdr_deli": "Deli", - "cv_fdr_beverage": "Beverage", - "cv_fdr_meat": "Meat", - "cv_fdr_soft_freezer": "Soft Freezer" - } + "cycle_active": { + "name": "[%key:component::binary_sensor::entity_component::running::name%]" }, - "dishwasher_cycle": { - "state": { - "0e": "AI Wash", - "07": "Pre blast", - "90": "Self clean", - "86": "Normal", - "83": "Express 60", - "84": "Heavy", - "8d": "Pots and pans", - "80": "Delicate", - "8e": "Plastic", - "8f": "Baby Care" - } + "defrost_active": { + "name": "Defrost active" }, - "washer_cycle_table_02": { - "state": { - "1c": "Eco 40-60", - "1d": "Super Speed", - "21": "Colours", - "1b": "Cotton", - "1e": "15' Quick Wash", - "29": "Drum Clean+", - "24": "Towels", - "33": "Bedding", - "28": "Drain/Spin", - "26": "Delicates", - "27": "Rinse+Spin", - "22": "Wool", - "20": "Hygiene Steam", - "23": "Outdoor", - "25": "Synthetics", - "32": "Shirts", - "2f": "Activewear", - "2e": "Baby Care", - "30": "Cloudy Day", - "66": "Denim", - "2d": "Silent Wash", - "8f": "Intense Cold", - "96": "Less Microfiber", - "36": "Wash+Dry", - "37": "Air Wash", - "38": "Cotton Dry", - "39": "Synthetics Dry", - "1f": "Intense Cold" - } + "detergent_low": { + "name": "Detergent low" }, - "dryer_cycle_table_03": { - "state": { - "16": "Cotton", - "18": "Synthetics", - "19": "Delicates", - "1a": "Wool", - "1b": "Bedding", - "1c": "Shirts", - "1d": "Towels", - "1e": "Outdoor", - "1f": "Mixed Load", - "20": "Iron Dry", - "23": "Quick Dry 35", - "24": "Cool Air", - "25": "Warm Air", - "27": "Time Dry" - } + "device_active": { + "name": "Device active" }, - "washer_dosing_quantity": { - "state": { - "00": "None", - "01": "Low", - "02": "Medium", - "03": "High" - } + "door_open": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" }, - "washer_detergent_water_hardness": { - "state": { - "01": "Soft", - "02": "Medium", - "03": "Hard" - } + "firmware_update": { + "name": "Firmware update available" }, - "washer_softener_concentration": { - "state": { - "01": "1x", - "02": "2x", - "03": "3x" - } + "instance_open": { + "name": "{instance_name} open" }, - "washer_dry_level": { - "state": { - "none": "Off", - "cupboard": "Cupboard", - "30": "30 min", - "60": "1 hr", - "90": "1 hr 30", - "120": "2 hr", - "180": "3 hr", - "240": "4 hr" - } + "paired_hood_connected": { + "name": "Paired hood connected" + }, + "paired_hood_light": { + "name": "Paired hood light" + }, + "paired_hood_power": { + "name": "Paired hood power" + }, + "periodic_air_sensing": { + "name": "Periodic air sensing" + }, + "power_state": { + "name": "Power state" + }, + "rapid_freezing": { + "name": "Rapid freezing" + }, + "rapid_fridge": { + "name": "Rapid fridge" + }, + "remote_control": { + "name": "Smart Control" + }, + "softener_low": { + "name": "Softener low" } }, - "sensor": { - "ice_making_status": { - "state": { - "icestatus_stop": "Idle", - "icestatus_run": "Making ice" - } + "button": { + "diagnosis_start": { + "name": "Start diagnosis" }, - "connection_mode": { - "state": { - "observe": "Push (observe)", - "poll": "Polling" - } + "pause": { + "name": "[%key:common::action::pause%]" }, - "machine_state": { - "state": { - "idle": "Idle", - "active": "Active", - "pause": "Paused" - } + "selfcheck_start": { + "name": "Start self-check" }, - "air_filter_status": { - "state": { - "normal": "Normal", - "wash": "Wash", - "replace": "Replace" - } + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" } }, "climate": { @@ -189,24 +90,11 @@ "state_attributes": { "fan_mode": { "state": { - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", "turbo": "Turbo" } }, - "swing_mode": { - "state": { - "off": "Fixed", - "both": "All directions", - "vertical": "Up and down" - } - }, "preset_mode": { "state": { - "none": "Off", - "sleep": "Sleep", "quiet": "Quiet", "smart": "Smart", "speed": "Speed" @@ -214,17 +102,625 @@ } } } + }, + "number": { + "cook_time": { + "name": "Cook time" + }, + "delay_start_hours": { + "name": "Delay start" + }, + "instance_setpoint": { + "name": "{instance_name} setpoint" + }, + "oven_setpoint": { + "name": "Setpoint" + }, + "sound_volume": { + "name": "Sound volume" + } + }, + "select": { + "ai_energy_level": { + "name": "AI Energy Mode level" + }, + "beverage_zone_mode": { + "name": "Beverage zone mode", + "state": { + "sp_ttype_beer_drinks": "Beverage", + "sp_ttype_wine_dessert": "Wine and dessert" + } + }, + "brightness_level": { + "name": "Night brightness", + "state": { + "33": "[%key:common::state::low%]", + "66": "[%key:common::state::medium%]", + "100": "[%key:common::state::high%]" + } + }, + "day_brightness": { + "name": "Cabinet brightness", + "state": { + "33": "[%key:common::state::low%]", + "66": "[%key:common::state::medium%]", + "100": "[%key:common::state::high%]" + } + }, + "buzzer_sound": { + "name": "Buzzer sound", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "cycle": { + "name": "Cycle" + }, + "detergent_quantity": { + "name": "Detergent quantity", + "state": { + "00": "None", + "01": "[%key:common::state::low%]", + "02": "[%key:common::state::medium%]", + "03": "[%key:common::state::high%]" + } + }, + "detergent_water_hardness": { + "name": "Detergent water hardness", + "state": { + "01": "Soft", + "02": "[%key:common::state::medium%]", + "03": "Hard" + } + }, + "dishwasher_cycle": { + "name": "Cycle", + "state": { + "80": "Delicate", + "83": "Express 60", + "84": "Heavy", + "86": "Normal", + "90": "Self clean", + "0e": "AI Wash", + "07": "Pre blast", + "8d": "Pots and pans", + "8e": "Plastic", + "8f": "Baby Care" + } + }, + "door_alert": { + "name": "Door alarm", + "state": { + "1": "Alarm 1", + "2": "Alarm 2", + "3": "Alarm 3", + "4": "Alarm 4" + } + }, + "dryer_cycle_table_03": { + "name": "Cycle", + "state": { + "16": "Cotton", + "18": "Synthetics", + "19": "Delicates", + "20": "Iron dry", + "23": "Quick Dry 35", + "24": "Cool air", + "25": "Warm air", + "27": "Time dry", + "1a": "Wool", + "1b": "Bedding", + "1c": "Shirts", + "1d": "Towels", + "1e": "Outdoor", + "1f": "Mixed load" + } + }, + "finish_sound": { + "name": "Finish sound", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "flex_zone_mode": { + "name": "Flex zone mode", + "state": { + "cv_ttype_rf9000a_freeze": "Freeze", + "cv_ttype_rf9000a_softfreeze": "Soft freeze", + "cv_ttype_rf9000a_meat_fish": "Meat/Fish", + "cv_ttype_rf9000a_fruit_veggies": "Fruit & vegetables", + "cv_ttype_rf9000a_beverage": "Beverage", + "cv_fdr_wine": "Wine", + "cv_fdr_deli": "Deli", + "cv_fdr_beverage": "Beverage", + "cv_fdr_meat": "Meat", + "cv_fdr_soft_freezer": "Soft freezer" + } + }, + "heated_dry": { + "name": "Smart Dry", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]", + "extra_high": "Extra high" + } + }, + "ice_type": { + "name": "{instance_name} type", + "state": { + "off": "[%key:common::state::off%]", + "whiskey_iceball_3": "3 balls/day", + "whiskey_iceball_6": "6 balls/day", + "whiskey_iceball_9": "9 balls/day" + } + }, + "led_brightness": { + "name": "Door LED brightness", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "led_night_brightness": { + "name": "Door LED night brightness", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "oven_mode": { + "name": "Cooking mode", + "state": { + "no_operation": "No operation", + "bake": "Bake", + "broil": "Broil", + "convection": "Convection", + "convection_bake": "Convection bake", + "convection_broil": "Convection broil", + "frozen_pizza_plus": "Frozen Pizza+", + "slow_cook": "Slow cook", + "plate_warm": "Plate warm", + "air_fry": "Air fry" + } + }, + "pantry_zone_mode": { + "name": "Pantry zone mode", + "state": { + "fdr_wine": "Wine", + "fdr_deli": "Deli", + "fdr_drinks": "Drinks" + } + }, + "range_burner_power_level": { + "name": "Burner {number} power level", + "state": { + "0": "[%key:common::state::off%]", + "boost": "Boost", + "simmer": "Simmer" + } + }, + "range_hood_lamp_brightness": { + "name": "Lamp brightness", + "state": { + "1": "Level 1", + "2": "Level 2" + } + }, + "rinse_cycles": { + "name": "Rinse cycles" + }, + "softener_concentration": { + "name": "Softener concentration", + "state": { + "01": "1x", + "02": "2x", + "03": "3x" + } + }, + "softener_quantity": { + "name": "Softener quantity", + "state": { + "00": "None", + "01": "[%key:common::state::low%]", + "02": "[%key:common::state::medium%]", + "03": "[%key:common::state::high%]" + } + }, + "sound_mode": { + "name": "Sound mode", + "state": { + "voice": "Voice", + "tone": "Tone", + "mute": "Mute" + } + }, + "spin_speed": { + "name": "Spin speed", + "state": { + "rinse_hold": "Rinse hold", + "no_spin": "No spin", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } + }, + "wash_temperature": { + "name": "Wash temperature", + "state": { + "none": "None", + "cold": "Cold", + "cool": "Cool", + "warm": "Warm", + "hot": "Hot", + "extra_hot": "Extra hot" + } + }, + "washer_cycle_table_02": { + "name": "Cycle", + "state": { + "20": "Hygiene Steam", + "21": "Colours", + "22": "Wool", + "23": "Outdoor", + "24": "Towels", + "25": "Synthetics", + "26": "Delicates", + "27": "Rinse+Spin", + "28": "Drain/Spin", + "29": "Drum Clean+", + "30": "Cloudy Day", + "32": "Shirts", + "33": "Bedding", + "36": "Wash+Dry", + "37": "Air Wash", + "38": "Cotton Dry", + "39": "Synthetics Dry", + "66": "Denim", + "96": "Less Microfiber", + "1c": "Eco 40-60", + "1d": "Super Speed", + "1b": "Cotton", + "1e": "15' Quick Wash", + "2f": "Activewear", + "2e": "Baby Care", + "2d": "Silent Wash", + "8f": "Intense Cold", + "1f": "Intense Cold" + } + }, + "washer_dry_level": { + "name": "Dry level", + "state": { + "30": "30 min", + "60": "1 hr", + "90": "1 hr 30", + "120": "2 hr", + "180": "3 hr", + "240": "4 hr", + "none": "[%key:common::state::off%]", + "cupboard": "Cupboard" + } + } + }, + "sensor": { + "air_filter_usage": { + "name": "Filter usage" + }, + "air_sensing_state": { + "name": "Air sensing state" + }, + "alarm_code": { + "name": "Alarm code" + }, + "auto_ventilation_action": { + "name": "Auto ventilation action" + }, + "automatic_ventilation_state": { + "name": "Automatic ventilation state" + }, + "burner_state": { + "name": "Burner {number} state" + }, + "clean_level": { + "name": "Clean level" + }, + "connection_mode": { + "name": "Connection mode", + "state": { + "observe": "Push (observe)", + "poll": "Polling" + } + }, + "cooktop_state": { + "name": "Cooktop state" + }, + "current_limit_level": { + "name": "Current limit level" + }, + "current_temp_c": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "diagnosis": { + "name": "Diagnosis" + }, + "diagnosis_status": { + "name": "Diagnosis status" + }, + "drum_clean_cycles_remaining": { + "name": "Drum clean due in" + }, + "drum_clean_last_cleaned": { + "name": "Drum last cleaned" + }, + "dry_level": { + "name": "Dry level" + }, + "dry_time": { + "name": "Dry time" + }, + "dryer_type": { + "name": "Dryer type" + }, + "dust": { + "name": "Dust" + }, + "energy_kwh": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "energy_last_month_kwh": { + "name": "Energy (last month)" + }, + "energy_saved_kwh": { + "name": "Energy saved" + }, + "energy_this_month_kwh": { + "name": "Energy (this month)" + }, + "fan_direction": { + "name": "Fan direction" + }, + "fan_speed_level": { + "name": "Fan speed level" + }, + "filter_progress": { + "name": "Filter progress" + }, + "fine_dust": { + "name": "Fine dust" + }, + "finish_time": { + "name": "Estimated finish" + }, + "freezer_temperature": { + "name": "Freezer temperature" + }, + "fridge_temperature": { + "name": "Fridge temperature" + }, + "hood_filter_capacity": { + "name": "Filter capacity" + }, + "hood_filter_usage": { + "name": "Filter usage" + }, + "instance_temperature": { + "name": "{instance_name} temperature" + }, + "job_beginning_status": { + "name": "Job beginning status" + }, + "last_air_sensing_level": { + "name": "Last air sensing level" + }, + "last_air_sensing_time": { + "name": "Last air sensing time" + }, + "main_timer_current": { + "name": "Timer current value" + }, + "main_timer_state": { + "name": "Timer state" + }, + "odor": { + "name": "Odor" + }, + "operating_mode": { + "name": "Operating mode" + }, + "operation_origin": { + "name": "Last operation source" + }, + "operation_time_minutes": { + "name": "Operation time" + }, + "oven_state": { + "name": "Cavity state" + }, + "paired_hood_fan_speed": { + "name": "Paired hood fan speed" + }, + "paired_hood_firmware": { + "name": "Paired hood firmware" + }, + "paired_hood_model": { + "name": "Paired hood model" + }, + "power_energy_kwh": { + "name": "Power energy" + }, + "power_watts": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "progress": { + "name": "Progress" + }, + "progress_percentage": { + "name": "Progress percent" + }, + "selfcheck_error": { + "name": "Self-check error" + }, + "selfcheck_result": { + "name": "Self-check result" + }, + "selfcheck_status": { + "name": "Self-check status" + }, + "super_fine_dust": { + "name": "Super fine dust" + }, + "water_liters": { + "name": "Water consumption" + }, + "machine_state": { + "name": "Machine state", + "state": { + "idle": "[%key:common::state::idle%]", + "active": "[%key:common::state::active%]", + "pause": "Paused" + } + }, + "filter_status": { + "name": "Filter status", + "state": { + "normal": "[%key:common::state::normal%]", + "wash": "Wash", + "replace": "Replace" + } + }, + "ice_making_status": { + "name": "{instance_name} making status", + "state": { + "icestatus_stop": "[%key:common::state::idle%]", + "icestatus_run": "Making ice" + } + } + }, + "switch": { + "ai_energy_level": { + "name": "AI Energy Mode" + }, + "air_purify": { + "name": "Air purification" + }, + "auto_clean": { + "name": "Auto clean" + }, + "auto_door_opener": { + "name": "Auto door opener" + }, + "auto_release_dry": { + "name": "Auto Release Dry" + }, + "autofill": { + "name": "Autofill pitcher" + }, + "bubble_soak": { + "name": "Bubble Soak" + }, + "cabinet_light_dim": { + "name": "Brighten gradually" + }, + "cabinet_light_switch": { + "name": "Cabinet light" + }, + "child_lock": { + "name": "Child lock" + }, + "defrost_delay": { + "name": "Defrost delay" + }, + "display_light": { + "name": "Display light" + }, + "fast_preheat": { + "name": "Fast preheat" + }, + "fridge_sound": { + "name": "Sound" + }, + "ice_maker_enabled": { + "name": "Ice maker" + }, + "ice_night_mode": { + "name": "Nighttime ice quiet mode" + }, + "instance_enabled": { + "name": "{instance_name} enabled" + }, + "intensive": { + "name": "Intensive" + }, + "lamp": { + "name": "Lamp" + }, + "led_night_light": { + "name": "Door LED night light" + }, + "mute_once": { + "name": "Mute once" + }, + "natural_steam": { + "name": "Natural steam" + }, + "power_switch": { + "name": "Power" + }, + "pre_wash": { + "name": "Pre-wash" + }, + "rapid_freezing": { + "name": "Rapid freezing" + }, + "rapid_fridge": { + "name": "Rapid fridge" + }, + "sabbath_mode": { + "name": "Sabbath mode" + }, + "sanitize": { + "name": "Sanitize" + }, + "sound": { + "name": "Sound" + }, + "storm_wash": { + "name": "Storm Wash+" + }, + "welcome_lighting": { + "name": "Welcome lighting" + }, + "wrinkle_prevent": { + "name": "Wrinkle prevent" + } + }, + "time": { + "led_night_end": { + "name": "Door LED night end" + }, + "led_night_start": { + "name": "Door LED night start" + }, + "night_end": { + "name": "Night light end" + }, + "night_start": { + "name": "Night light start" + } } }, "config": { "step": { "user": { - "title": "Add Samsung Appliance", - "description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}", + "title": "Add Samsung appliance", + "description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates.", "data": { - "host": "IP Address", - "ca_cert_pem": "CA Certificate (PEM)", - "ca_key_pem": "CA Private Key (PEM)" + "host": "IP address", + "ca_cert_pem": "CA certificate (PEM)", + "ca_key_pem": "CA private key (PEM)" }, "data_description": { "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).", @@ -232,9 +728,23 @@ "ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer." } }, + "user_reuse": { + "title": "Add Samsung appliance", + "description": "Enter the IP address of your Samsung appliance. The CA credentials from an existing LocalThings appliance will be reused.", + "data": { + "host": "IP address" + }, + "data_description": { + "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50)." + } + }, "confirm_unknown_type": { "title": "Appliance type not recognized", "description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." + }, + "confirm_unknown_type_no_version": { + "title": "Appliance type not recognized", + "description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." } }, "error": { @@ -243,13 +753,13 @@ "unknown": "Unexpected error. Check the Home Assistant logs for details." }, "abort": { - "already_configured": "This device is already configured." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { "step": { "init": { - "title": "Local Things Options", + "title": "LocalThings options", "menu_options": { "settings": "Remote control write settings", "debug_write": "Debug: write to a resource" @@ -298,5 +808,25 @@ "title": "Incomplete capability coverage for {device_name}", "description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template." } + }, + "exceptions": { + "remote_control_disabled": { + "message": "Remote control is turned off on this device. Check your appliance's manual for how to enable remote control before Home Assistant can control it." + }, + "debug_payload_empty": { + "message": "Debug write payload must be a non-empty object." + }, + "resource_href_required": { + "message": "A resource href is required." + }, + "bubble_soak_unavailable_for_cycle": { + "message": "Bubble Soak isn't available on the selected cycle." + }, + "pre_wash_unavailable_for_cycle": { + "message": "Pre-wash isn't available on the selected cycle." + }, + "intensive_unavailable_for_cycle": { + "message": "Intensive isn't available on the selected cycle." + } } } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index abea1fc..05510d0 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1,7 +1,196 @@ { "entity": { + "binary_sensor": { + "any_burner_active": { + "name": "Een brander actief" + }, + "automatic_operation": { + "name": "Automatische werking" + }, + "burner_hot_surface": { + "name": "Brander {number} heet oppervlak" + }, + "cloud_connected": { + "name": "Verbonden met cloud" + }, + "cooktop_safety_shutoff_enabled": { + "name": "Automatische uitschakeling heet oppervlak ingeschakeld" + }, + "current_limit_enabled": { + "name": "Stroombegrenzing ingeschakeld" + }, + "cycle_active": { + "name": "[%key:component::binary_sensor::entity_component::running::name%]" + }, + "defrost_active": { + "name": "Ontdooien actief" + }, + "detergent_low": { + "name": "Wasmiddel bijna op" + }, + "device_active": { + "name": "Apparaat actief" + }, + "door_open": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, + "firmware_update": { + "name": "Firmware-update beschikbaar" + }, + "instance_open": { + "name": "{instance_name} open" + }, + "paired_hood_connected": { + "name": "Gekoppelde afzuigkap verbonden" + }, + "paired_hood_light": { + "name": "Verlichting gekoppelde afzuigkap" + }, + "paired_hood_power": { + "name": "Gekoppelde afzuigkap actief" + }, + "periodic_air_sensing": { + "name": "Periodieke luchtmeting" + }, + "power_state": { + "name": "Voedingsstatus" + }, + "rapid_freezing": { + "name": "Snelvriezen" + }, + "rapid_fridge": { + "name": "Snelkoelen" + }, + "remote_control": { + "name": "Smart Control" + }, + "softener_low": { + "name": "Wasverzachter bijna op" + } + }, + "button": { + "diagnosis_start": { + "name": "Diagnose starten" + }, + "pause": { + "name": "[%key:common::action::pause%]" + }, + "selfcheck_start": { + "name": "Zelfcontrole starten" + }, + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" + } + }, + "climate": { + "airconditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "turbo": "Turbo" + } + }, + "preset_mode": { + "state": { + "quiet": "Stil", + "smart": "Slim", + "speed": "Snel" + } + } + } + } + }, + "number": { + "cook_time": { + "name": "Bereidingstijd" + }, + "delay_start_hours": { + "name": "Uitgestelde start" + }, + "instance_setpoint": { + "name": "Instelpunt {instance_name}" + }, + "oven_setpoint": { + "name": "Instelpunt" + }, + "sound_volume": { + "name": "Geluidsvolume" + } + }, "select": { + "ai_energy_level": { + "name": "Niveau AI Energy Mode" + }, + "beverage_zone_mode": { + "name": "Modus drankenzone", + "state": { + "sp_ttype_beer_drinks": "Dranken", + "sp_ttype_wine_dessert": "Wijn en dessert" + } + }, + "brightness_level": { + "name": "Nachthelderheid", + "state": { + "33": "[%key:common::state::low%]", + "66": "[%key:common::state::medium%]", + "100": "[%key:common::state::high%]" + } + }, + "day_brightness": { + "name": "Helderheid kast", + "state": { + "33": "[%key:common::state::low%]", + "66": "[%key:common::state::medium%]", + "100": "[%key:common::state::high%]" + } + }, + "buzzer_sound": { + "name": "Zoemergeluid", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "cycle": { + "name": "Programma" + }, + "detergent_quantity": { + "name": "Hoeveelheid wasmiddel", + "state": { + "00": "Geen", + "01": "[%key:common::state::low%]", + "02": "[%key:common::state::medium%]", + "03": "[%key:common::state::high%]" + } + }, + "detergent_water_hardness": { + "name": "Waterhardheid voor wasmiddel", + "state": { + "01": "Zacht", + "02": "[%key:common::state::medium%]", + "03": "Hard" + } + }, + "dishwasher_cycle": { + "name": "Programma", + "state": { + "80": "Delicaat", + "83": "Express 60", + "84": "Intensief", + "86": "Normaal", + "90": "Zelfreiniging", + "0e": "AI Wash", + "07": "Voorspoelen", + "8d": "Potten en pannen", + "8e": "Kunststof", + "8f": "Babyverzorging" + } + }, "door_alert": { + "name": "Deuralarm", "state": { "1": "Alarm 1", "2": "Alarm 2", @@ -9,101 +198,8 @@ "4": "Alarm 4" } }, - "brightness_level": { - "state": { - "33": "Laag", - "66": "Gemiddeld", - "100": "Hoog" - } - }, - "range_hood_lamp_brightness": { - "state": { - "1": "Niveau 1", - "2": "Niveau 2" - } - }, - "ice_type": { - "state": { - "off": "Uit", - "whiskey_iceball_3": "3 ijsballen per dag", - "whiskey_iceball_6": "6 ijsballen per dag", - "whiskey_iceball_9": "9 ijsballen per dag" - } - }, - "beverage_zone_mode": { - "state": { - "sp_ttype_beer_drinks": "Dranken", - "sp_ttype_wine_dessert": "Wijn en dessert" - } - }, - "pantry_zone_mode": { - "state": { - "fdr_wine": "Wijn", - "fdr_deli": "Delicatessen", - "fdr_drinks": "Dranken" - } - }, - "flex_zone_mode": { - "state": { - "cv_ttype_rf9000a_freeze": "Vriezen", - "cv_ttype_rf9000a_softfreeze": "Zachtvriezen", - "cv_ttype_rf9000a_meat_fish": "Vlees/vis", - "cv_ttype_rf9000a_fruit_veggies": "Fruit en groenten", - "cv_ttype_rf9000a_beverage": "Dranken", - "cv_fdr_wine": "Wijn", - "cv_fdr_deli": "Delicatessen", - "cv_fdr_beverage": "Dranken", - "cv_fdr_meat": "Vlees", - "cv_fdr_soft_freezer": "Zachtvriezer" - } - }, - "dishwasher_cycle": { - "state": { - "80": "Delicaat", - "83": "Express 60", - "84": "Intensief", - "86": "Normaal", - "90": "Zelfreiniging", - "0e": "AI wassen", - "07": "Voorspoelen", - "8d": "Potten en pannen", - "8e": "Kunststof", - "8f": "Babyverzorging" - } - }, - "washer_cycle_table_02": { - "state": { - "20": "Hygiënisch stomen", - "21": "Bonte was", - "22": "Wol", - "23": "Outdoor", - "24": "Handdoeken", - "25": "Synthetisch", - "26": "Fijne was", - "27": "Spoelen+centrifugeren", - "28": "Afpompen/centrifugeren", - "29": "Trommelreiniging+", - "30": "Bewolkte dag", - "32": "Overhemden", - "33": "Beddengoed", - "36": "Wassen+drogen", - "37": "Air Wash", - "38": "Katoen drogen", - "39": "Synthetisch drogen", - "66": "Spijkergoed", - "96": "Minder microvezels", - "1c": "Eco 40-60", - "1d": "Super Speed", - "1b": "Katoen", - "1e": "Kort programma 15'", - "2f": "Sportkleding", - "2e": "Babykleding", - "2d": "Stille was", - "8f": "Intensief koud", - "1f": "Intensief koud" - } - }, "dryer_cycle_table_03": { + "name": "Programma", "state": { "16": "Katoen", "18": "Synthetisch", @@ -121,29 +217,182 @@ "1f": "Gemengde was" } }, - "washer_dosing_quantity": { + "finish_sound": { + "name": "Eindgeluid", "state": { - "00": "Geen", - "01": "Laag", - "02": "Gemiddeld", - "03": "Hoog" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } }, - "washer_detergent_water_hardness": { + "flex_zone_mode": { + "name": "Modus FlexZone", "state": { - "01": "Zacht", - "02": "Gemiddeld", - "03": "Hard" + "cv_ttype_rf9000a_freeze": "Vriezen", + "cv_ttype_rf9000a_softfreeze": "Zachtvriezen", + "cv_ttype_rf9000a_meat_fish": "Vlees/vis", + "cv_ttype_rf9000a_fruit_veggies": "Fruit en groenten", + "cv_ttype_rf9000a_beverage": "Dranken", + "cv_fdr_wine": "Wijn", + "cv_fdr_deli": "Delicatessen", + "cv_fdr_beverage": "Dranken", + "cv_fdr_meat": "Vlees", + "cv_fdr_soft_freezer": "Zachtvriezer" } }, - "washer_softener_concentration": { + "heated_dry": { + "name": "Smart Dry", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]", + "extra_high": "Extra hoog" + } + }, + "ice_type": { + "name": "Type {instance_name}", + "state": { + "off": "[%key:common::state::off%]", + "whiskey_iceball_3": "3 ijsballen per dag", + "whiskey_iceball_6": "6 ijsballen per dag", + "whiskey_iceball_9": "9 ijsballen per dag" + } + }, + "led_brightness": { + "name": "Helderheid deur-LED", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "led_night_brightness": { + "name": "Nachtelijke helderheid deur-LED", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "oven_mode": { + "name": "Bereidingsmodus", + "state": { + "no_operation": "Niet actief", + "bake": "Bakken", + "broil": "Grillen", + "convection": "Hetelucht", + "convection_bake": "Bakken met hetelucht", + "convection_broil": "Grillen met hetelucht", + "frozen_pizza_plus": "Frozen Pizza+", + "slow_cook": "Langzaam garen", + "plate_warm": "Borden warmhouden", + "air_fry": "Airfryen" + } + }, + "pantry_zone_mode": { + "name": "Modus voorraadzone", + "state": { + "fdr_wine": "Wijn", + "fdr_deli": "Delicatessen", + "fdr_drinks": "Dranken" + } + }, + "range_burner_power_level": { + "name": "Vermogensniveau brander {number}", + "state": { + "0": "[%key:common::state::off%]", + "boost": "Boost", + "simmer": "Sudderen" + } + }, + "range_hood_lamp_brightness": { + "name": "Helderheid lamp", + "state": { + "1": "Niveau 1", + "2": "Niveau 2" + } + }, + "rinse_cycles": { + "name": "Spoelcycli" + }, + "softener_concentration": { + "name": "Concentratie wasverzachter", "state": { "01": "1x", "02": "2x", "03": "3x" } }, + "softener_quantity": { + "name": "Hoeveelheid wasverzachter", + "state": { + "00": "Geen", + "01": "[%key:common::state::low%]", + "02": "[%key:common::state::medium%]", + "03": "[%key:common::state::high%]" + } + }, + "sound_mode": { + "name": "Geluidsmodus", + "state": { + "voice": "Spraak", + "tone": "Toon", + "mute": "Dempen" + } + }, + "spin_speed": { + "name": "Centrifugesnelheid", + "state": { + "rinse_hold": "Spoelstop", + "no_spin": "Niet centrifugeren", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } + }, + "wash_temperature": { + "name": "Wastemperatuur", + "state": { + "none": "Geen", + "cold": "Koud", + "cool": "Koel", + "warm": "Warm", + "hot": "Heet", + "extra_hot": "Extra heet" + } + }, + "washer_cycle_table_02": { + "name": "Programma", + "state": { + "20": "Hygiënisch stomen", + "21": "Bonte was", + "22": "Wol", + "23": "Outdoor", + "24": "Handdoeken", + "25": "Synthetisch", + "26": "Fijne was", + "27": "Spoelen+centrifugeren", + "28": "Afpompen/centrifugeren", + "29": "Drum Clean+", + "30": "Bewolkte dag", + "32": "Overhemden", + "33": "Beddengoed", + "36": "Wassen+drogen", + "37": "Air Wash", + "38": "Katoen drogen", + "39": "Synthetisch drogen", + "66": "Spijkergoed", + "96": "Minder microvezels", + "1c": "Eco 40-60", + "1d": "Super Speed", + "1b": "Katoen", + "1e": "15' Quick Wash", + "2f": "Sportkleding", + "2e": "Babyverzorging", + "2d": "Stille was", + "8f": "Intensief koud", + "1f": "Intensief koud" + } + }, "washer_dry_level": { + "name": "Droogniveau", "state": { "30": "30 min", "60": "1 uur", @@ -151,68 +400,315 @@ "120": "2 uur", "180": "3 uur", "240": "4 uur", - "none": "Uit", + "none": "[%key:common::state::off%]", "cupboard": "Kastdroog" } } }, "sensor": { - "ice_making_status": { - "state": { - "icestatus_stop": "Inactief", - "icestatus_run": "IJs maken" - } + "air_filter_usage": { + "name": "Filterverbruik" + }, + "air_sensing_state": { + "name": "Status luchtmeting" + }, + "alarm_code": { + "name": "Alarmcode" + }, + "auto_ventilation_action": { + "name": "Actie automatische ventilatie" + }, + "automatic_ventilation_state": { + "name": "Status automatische ventilatie" + }, + "burner_state": { + "name": "Status brander {number}" + }, + "clean_level": { + "name": "Reinigingsniveau" }, "connection_mode": { + "name": "Verbindingsmodus", "state": { "observe": "Push (observeren)", "poll": "Polling" } }, + "cooktop_state": { + "name": "Status kookplaat" + }, + "current_limit_level": { + "name": "Niveau stroombegrenzing" + }, + "current_temp_c": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "diagnosis": { + "name": "Diagnose" + }, + "diagnosis_status": { + "name": "Diagnosestatus" + }, + "drum_clean_cycles_remaining": { + "name": "Trommelreiniging over" + }, + "drum_clean_last_cleaned": { + "name": "Trommel laatst gereinigd" + }, + "dry_level": { + "name": "Droogniveau" + }, + "dry_time": { + "name": "Droogtijd" + }, + "dryer_type": { + "name": "Type droger" + }, + "dust": { + "name": "Stof" + }, + "energy_kwh": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "energy_last_month_kwh": { + "name": "Energie (vorige maand)" + }, + "energy_saved_kwh": { + "name": "Bespaarde energie" + }, + "energy_this_month_kwh": { + "name": "Energie (deze maand)" + }, + "fan_direction": { + "name": "Ventilatorrichting" + }, + "fan_speed_level": { + "name": "Ventilatorniveau" + }, + "filter_progress": { + "name": "Filterverbruik" + }, + "fine_dust": { + "name": "Fijnstof" + }, + "finish_time": { + "name": "Geschatte eindtijd" + }, + "freezer_temperature": { + "name": "Vriezertemperatuur" + }, + "fridge_temperature": { + "name": "Koelkasttemperatuur" + }, + "hood_filter_capacity": { + "name": "Filtercapaciteit" + }, + "hood_filter_usage": { + "name": "Filterverbruik" + }, + "instance_temperature": { + "name": "Temperatuur {instance_name}" + }, + "job_beginning_status": { + "name": "Status programmastart" + }, + "last_air_sensing_level": { + "name": "Laatste luchtmeetniveau" + }, + "last_air_sensing_time": { + "name": "Tijdstip laatste luchtmeting" + }, + "main_timer_current": { + "name": "Huidige timerwaarde" + }, + "main_timer_state": { + "name": "Timerstatus" + }, + "odor": { + "name": "Geur" + }, + "operating_mode": { + "name": "Werkingsmodus" + }, + "operation_origin": { + "name": "Bron laatste bediening" + }, + "operation_time_minutes": { + "name": "Werkingsduur" + }, + "oven_state": { + "name": "Status ovenruimte" + }, + "paired_hood_fan_speed": { + "name": "Ventilatorsnelheid gekoppelde afzuigkap" + }, + "paired_hood_firmware": { + "name": "Firmware gekoppelde afzuigkap" + }, + "paired_hood_model": { + "name": "Model gekoppelde afzuigkap" + }, + "power_energy_kwh": { + "name": "Energieverbruik" + }, + "power_watts": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "progress": { + "name": "Voortgang" + }, + "progress_percentage": { + "name": "Voortgangspercentage" + }, + "selfcheck_error": { + "name": "Fout zelfcontrole" + }, + "selfcheck_result": { + "name": "Resultaat zelfcontrole" + }, + "selfcheck_status": { + "name": "Status zelfcontrole" + }, + "super_fine_dust": { + "name": "Ultrafijnstof" + }, + "water_liters": { + "name": "Waterverbruik" + }, "machine_state": { + "name": "Machinestatus", "state": { - "idle": "Inactief", - "active": "Actief", + "idle": "[%key:common::state::idle%]", + "active": "[%key:common::state::active%]", "pause": "Gepauzeerd" } }, - "air_filter_status": { + "filter_status": { + "name": "Filterstatus", "state": { - "normal": "Normaal", - "wash": "Wassen", + "normal": "[%key:common::state::normal%]", + "wash": "Reinigen", "replace": "Vervangen" } + }, + "ice_making_status": { + "name": "{instance_name} ijsmaakstatus", + "state": { + "icestatus_stop": "[%key:common::state::idle%]", + "icestatus_run": "IJs maken" + } } }, - "climate": { - "airconditioner": { - "state_attributes": { - "fan_mode": { - "state": { - "auto": "Automatisch", - "low": "Laag", - "medium": "Gemiddeld", - "high": "Hoog", - "turbo": "Turbo" - } - }, - "swing_mode": { - "state": { - "off": "Vast", - "both": "Alle richtingen", - "vertical": "Omhoog en omlaag" - } - }, - "preset_mode": { - "state": { - "none": "Uit", - "sleep": "Slaap", - "quiet": "Stil", - "smart": "Slim", - "speed": "Snel" - } - } - } + "switch": { + "ai_energy_level": { + "name": "AI Energy Mode" + }, + "air_purify": { + "name": "Luchtzuivering" + }, + "auto_clean": { + "name": "Automatisch reinigen" + }, + "auto_door_opener": { + "name": "Automatische deuropener" + }, + "auto_release_dry": { + "name": "Auto Release Dry" + }, + "autofill": { + "name": "Automatisch gevulde waterkan" + }, + "bubble_soak": { + "name": "Bubble Soak" + }, + "cabinet_light_dim": { + "name": "Geleidelijk feller" + }, + "cabinet_light_switch": { + "name": "Kastverlichting" + }, + "child_lock": { + "name": "Kinderslot" + }, + "defrost_delay": { + "name": "Ontdooien uitstellen" + }, + "display_light": { + "name": "Displayverlichting" + }, + "fast_preheat": { + "name": "Snel voorverwarmen" + }, + "fridge_sound": { + "name": "Geluid" + }, + "ice_maker_enabled": { + "name": "IJsmaker" + }, + "ice_night_mode": { + "name": "Stille nachtmodus ijsmaker" + }, + "instance_enabled": { + "name": "{instance_name} ingeschakeld" + }, + "intensive": { + "name": "Intensive" + }, + "lamp": { + "name": "Lamp" + }, + "led_night_light": { + "name": "Nachtverlichting deur-LED" + }, + "mute_once": { + "name": "Eenmalig dempen" + }, + "natural_steam": { + "name": "Natuurlijke stoom" + }, + "power_switch": { + "name": "Voeding" + }, + "pre_wash": { + "name": "Voorwas" + }, + "rapid_freezing": { + "name": "Snelvriezen" + }, + "rapid_fridge": { + "name": "Snelkoelen" + }, + "sabbath_mode": { + "name": "Sabbatmodus" + }, + "sanitize": { + "name": "Hygiëne" + }, + "sound": { + "name": "Geluid" + }, + "storm_wash": { + "name": "Storm Wash+" + }, + "welcome_lighting": { + "name": "Welkomstverlichting" + }, + "wrinkle_prevent": { + "name": "Kreukpreventie" + } + }, + "time": { + "led_night_end": { + "name": "Einde nachtverlichting deur-LED" + }, + "led_night_start": { + "name": "Start nachtverlichting deur-LED" + }, + "night_end": { + "name": "Einde nachtverlichting" + }, + "night_start": { + "name": "Start nachtverlichting" } } }, @@ -220,7 +716,7 @@ "step": { "user": { "title": "Samsung-apparaat toevoegen", - "description": "Voer het IP-adres van je Samsung-apparaat en de AC14K_M-CA-inloggegevens in waarmee apparaatcertificaten worden aangemaakt. {reuse_note}", + "description": "Voer het IP-adres van je Samsung-apparaat en de AC14K_M-CA-inloggegevens in waarmee apparaatcertificaten worden aangemaakt.", "data": { "host": "IP-adres", "ca_cert_pem": "CA-certificaat (PEM)", @@ -232,9 +728,23 @@ "ca_key_pem": "De PEM-gecodeerde AC14K_M-privésleutel. Plak de volledige inhoud, inclusief de kop- en voettekst BEGIN PRIVATE KEY en END PRIVATE KEY." } }, + "user_reuse": { + "title": "Samsung-apparaat toevoegen", + "description": "Voer het IP-adres van je Samsung-apparaat in. De CA-inloggegevens van een bestaand LocalThings-apparaat worden hergebruikt.", + "data": { + "host": "IP-adres" + }, + "data_description": { + "host": "Het lokale IP-adres van je Samsung-apparaat (bijvoorbeeld 192.168.1.50)." + } + }, "confirm_unknown_type": { "title": "Apparaattype niet herkend", "description": "Dit apparaat meldt oneUiVersion \"{one_ui_version}\", wat niet als apparaattype wordt herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen." + }, + "confirm_unknown_type_no_version": { + "title": "Apparaattype niet herkend", + "description": "Dit apparaat meldt geen oneUiVersion en het apparaattype kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen." } }, "error": { @@ -243,7 +753,7 @@ "unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie." }, "abort": { - "already_configured": "Dit apparaat is al geconfigureerd." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { @@ -259,7 +769,7 @@ "title": "Schrijfinstellingen voor afstandsbediening", "description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.", "data": { - "bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer de afstandsbediening als uitgeschakeld wordt gemeld" + "bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld" } }, "debug_write": { @@ -298,5 +808,25 @@ "title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}", "description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template." } + }, + "exceptions": { + "remote_control_disabled": { + "message": "Afstandsbediening is uitgeschakeld op dit apparaat. Raadpleeg de handleiding van je apparaat om afstandsbediening in te schakelen voordat Home Assistant het kan bedienen." + }, + "debug_payload_empty": { + "message": "De payload voor de schrijfbewerking voor foutopsporing moet een niet-leeg object zijn." + }, + "resource_href_required": { + "message": "Een resource-href is vereist." + }, + "bubble_soak_unavailable_for_cycle": { + "message": "Bubble Soak is niet beschikbaar voor het geselecteerde programma." + }, + "pre_wash_unavailable_for_cycle": { + "message": "Voorwas is niet beschikbaar voor het geselecteerde programma." + }, + "intensive_unavailable_for_cycle": { + "message": "Intensief is niet beschikbaar voor het geselecteerde programma." + } } } From b85af10ae1573fbe08fdb88c878433ed8cddd218 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:08:29 +0200 Subject: [PATCH 3/8] Test translation coverage and dynamic fallbacks --- tests/localthings/test_config_flow.py | 34 +++++++ tests/localthings/test_coordinator.py | 8 +- tests/test_dryer_capabilities.py | 2 +- tests/test_entity_naming.py | 41 +++++---- tests/test_laundry_capabilities.py | 25 ++--- tests/test_select_display.py | 9 ++ tests/test_select_options.py | 26 ++++++ tests/test_translations.py | 127 ++++++++++++++++++++++++++ tests/test_washer_capabilities.py | 29 +++--- 9 files changed, 252 insertions(+), 49 deletions(-) create mode 100644 tests/test_translations.py diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index bc7de8b..0f4fee5 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -38,6 +38,7 @@ async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None: DOMAIN, context={'source': 'user'} ) assert result['type'] == FlowResultType.FORM + assert result['step_id'] == 'user_reuse' assert CONF_CA_CERT_PEM not in result['data_schema'].schema assert CONF_CA_KEY_PEM not in result['data_schema'].schema @@ -219,6 +220,39 @@ async def test_unknown_type_shows_confirmation_step( assert result['data'][CONF_HOST] == MOCK_HOST +async def test_unknown_type_without_version_uses_localized_step( + hass: HomeAssistant, +) -> None: + """No English placeholder sentinel leaks into a translated description.""" + probe_result = { + 'port': MOCK_PORT, + 'serial': MOCK_SERIAL, + 'leaf_cert_pem': 'leaf cert', + 'leaf_key_pem': 'leaf key', + 'one_ui_version': '', + 'device_type_recognized': False, + } + with patch( + 'custom_components.localthings.config_flow._probe_and_validate', + return_value=probe_result, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + result = await hass.config_entries.flow.async_configure( + result['flow_id'], + { + CONF_HOST: MOCK_HOST, + CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, + CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM, + }, + ) + + assert result['type'] == FlowResultType.FORM + assert result['step_id'] == 'confirm_unknown_type_no_version' + assert not result.get('description_placeholders') + + async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None: """Second add of same serial: flow aborts. diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index cd137e3..0a1b420 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -914,9 +914,12 @@ async def test_send_command_blocked_when_remote_control_disabled( with patch.object(fake, 'subscribe'): fake.post = _post - with pytest.raises(ServiceValidationError, match="Remote control is turned off"): + with pytest.raises(ServiceValidationError) as exc_info: await coordinator.async_send_command(bound, 5) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == 'remote_control_disabled' + assert posted is False @@ -977,8 +980,9 @@ async def test_send_command_remote_control_check_precedes_validate_fn( desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn) bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) - with pytest.raises(ServiceValidationError, match="Remote control is turned off"): + with pytest.raises(ServiceValidationError) as exc_info: await coordinator.async_send_command(bound, 'On') + assert exc_info.value.translation_key == 'remote_control_disabled' async def test_send_command_bypasses_remote_control_when_option_enabled( diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index a6885cc..f2e25a9 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -69,7 +69,7 @@ def test_course_bound_to_shared_course_vs_0(): assert callable(desc.translation_key) table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}} assert desc.translation_key(table_03) == 'dryer_cycle_table_03' - assert desc.translation_key({}) is None + assert desc.translation_key({}) == 'cycle' assert desc.options is laundry.cycle_options rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']} assert desc.rep_fn(rep) == '16' diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py index 1b9bacf..44b73d4 100644 --- a/tests/test_entity_naming.py +++ b/tests/test_entity_naming.py @@ -1,7 +1,4 @@ -"""Tests for LocalThingsEntity's display-name derivation -(custom_components/localthings/entity.py) -- the explicit-name, -device-given-instance-name, and href-derived fallbacks. -""" +"""Tests for translated entity naming and dynamic instance placeholders.""" from custom_components.localthings.entity import LocalThingsEntity from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import BoundEntity @@ -23,23 +20,35 @@ def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_ return LocalThingsEntity(_FakeCoordinator(), bound) -def test_explicit_name_wins_over_everything(): - desc = BinarySensorDesc(key='enabled', name='Explicit Name') +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') entity = _make_entity(desc, instance_name='Cubed Ice') - assert entity._attr_name == 'Explicit Name' + assert entity.translation_key == 'enabled' + assert not hasattr(entity, '_attr_name') -def test_instance_name_prefixes_the_derived_suffix(): - """Issue #27: an ice maker's device-given name ("Cubed Ice") replaces - the href-derived instance label ("Icemaker One") as the name prefix, - keeping the same entity-specific suffix.""" - desc = BinarySensorDesc(key='enabled') +def test_device_instance_name_becomes_translation_placeholder(): + desc = BinarySensorDesc( + key='enabled', translation_key='instance_enabled', use_instance_name=True + ) entity = _make_entity(desc, key_override='icemaker_one_enabled', instance_name='Cubed Ice') - assert entity._attr_name == 'Cubed Ice Enabled' + assert entity.translation_key == 'instance_enabled' + assert entity.translation_placeholders == {'instance_name': 'Cubed Ice'} + assert not hasattr(entity, '_attr_name') -def test_no_instance_name_falls_back_to_derived_state_key(): - desc = BinarySensorDesc(key='enabled') +def test_href_instance_name_becomes_translation_placeholder(): + desc = BinarySensorDesc( + key='enabled', translation_key='instance_enabled', use_instance_name=True + ) entity = _make_entity(desc, key_override='icemaker_one_enabled') - assert entity._attr_name == 'Icemaker One Enabled' + assert entity.translation_placeholders == {'instance_name': 'Icemaker One'} + + +def test_untranslated_vendor_entity_keeps_readable_fallback_name(): + desc = BinarySensorDesc(key='vendor_feature') + entity = _make_entity(desc, key_override='vendor_feature_1') + assert entity.translation_key is None + assert entity._attr_name == 'Vendor Feature 1' diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index a9ea555..c123410 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -173,11 +173,9 @@ class TestCycleSelectTableGating: codes aren't guaranteed consistent across board generations sharing the same /course/vs/0 contract; FlexWash's older board reports a different course table than every device the shipped translations - were confirmed against). The resolved key is built from whatever table - the device actually reports -- a table with no strings.json entries - yet just falls through Home Assistant's own missing-translation - handling to raw-code display, the same as any individual untranslated - code within an existing table.""" + were confirmed against). The resolved key is built from a reported + table id. Devices without one use the generic translated Cycle entity + name while preserving raw option codes as a safe fallback.""" def _desc(self): return laundry.cycle_select( @@ -198,20 +196,17 @@ class TestCycleSelectTableGating: assert callable(desc.translation_key) assert desc.translation_key(resources) == 'washer_cycle_table_02' - def test_resolved_key_reflects_an_unbuilt_table_too(self): - """No gating against a hardcoded 'known good' table -- a table we - haven't shipped translations for yet still gets a key built for - it, just one strings.json has nothing under (raw-code display).""" + def test_untranslated_table_uses_generic_cycle_key(self): + """An unknown table does not claim another board's state labels.""" desc = self._desc() resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} - assert desc.translation_key(resources) == 'washer_cycle_table_00' + assert desc.translation_key(resources) == 'cycle' - def test_resolves_to_none_when_table_id_is_unknown(self): - """No href, or an empty rep, gets no translation_key at all -- - there's nothing to build a key from.""" + def test_resolves_to_generic_cycle_when_table_id_is_unknown(self): + """An absent table id still gets a translated generic entity name.""" desc = self._desc() - assert desc.translation_key({}) is None - assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None + assert desc.translation_key({}) == 'cycle' + assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle' class TestBuzzerSound: diff --git a/tests/test_select_display.py b/tests/test_select_display.py index bfc03df..3476c9a 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -34,5 +34,14 @@ def test_display_lowercases_for_translation_key_lookup(): assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3' +def test_unknown_translated_vendor_value_keeps_readable_fallback(): + """A firmware-added value must remain readable instead of being mangled.""" + assert _display('FutureVendorMode', _TRANSLATED) == 'Future Vendor Mode' + + +def test_known_camel_case_state_uses_snake_case_translation_key(): + assert _display('ExtraHigh', 'heated_dry') == 'extra_high' + + def test_display_passes_through_non_string_values(): assert _display(None, _UNTRANSLATED) is None diff --git a/tests/test_select_options.py b/tests/test_select_options.py index 293cc41..6a64719 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -74,3 +74,29 @@ def test_callable_translation_key_reresolves_live_not_once_at_construction(): resources['key'] = 'washer_cycle_table_02' assert entity.translation_key == 'washer_cycle_table_02' + + +async def test_unknown_vendor_option_round_trips_to_exact_raw_value(): + """Readable fallback labels must still write the exact Samsung token.""" + class _WritableCoordinator(_FakeCoordinator): + data = {'mode': 'FutureVendorMode'} + + def __init__(self, last_resources): + super().__init__(last_resources) + self.writes = [] + + async def async_send_command(self, bound, value): + self.writes.append(value) + + desc = SelectDesc( + key='mode', name='Mode', translation_key='door_alert', + options=('Known', 'FutureVendorMode'), write_fn=lambda *args: None, + ) + capability = Capability(href='/x/vs/0', entities=(desc,)) + bound = BoundEntity(href='/x/vs/0', capability=capability, desc=desc) + coordinator = _WritableCoordinator({}) + entity = LocalThingsSelect(coordinator, bound) + + assert entity.options[-1] == 'Future Vendor Mode' + await entity.async_select_option('Future Vendor Mode') + assert coordinator.writes == ['FutureVendorMode'] diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 0000000..ab94b81 --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,127 @@ +"""Translation architecture and catalog synchronization tests.""" + +from __future__ import annotations + +import importlib +import json +from pathlib import Path +from string import Formatter + +from custom_components.localthings.registry.capability import Capability +from custom_components.localthings.registry.entities import PLATFORM_OF +from custom_components.localthings.select import TRANSLATED_SELECT_STATES + + +INTEGRATION = ( + Path(__file__).parents[1] / "custom_components" / "localthings" +) + + +def _load(name: str) -> dict: + return json.loads((INTEGRATION / name).read_text(encoding="utf-8")) + + +def _topology(value): + if isinstance(value, dict): + return {key: _topology(child) for key, child in value.items()} + if isinstance(value, list): + return [_topology(child) for child in value] + return None + + +def _placeholders(value: str) -> set[str]: + return { + field_name + for _, field_name, _, _ in Formatter().parse(value) + if field_name is not None + } + + +def _walk_strings(value, path=()): + if isinstance(value, dict): + for key, child in value.items(): + yield from _walk_strings(child, (*path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + yield from _walk_strings(child, (*path, str(index))) + elif isinstance(value, str): + yield path, value + + +def _all_descriptions(): + capabilities_dir = INTEGRATION / "registry" / "capabilities" + seen: set[int] = set() + for module_path in capabilities_dir.glob("*.py"): + if module_path.stem == "__init__": + continue + module = importlib.import_module( + f"custom_components.localthings.registry.capabilities.{module_path.stem}" + ) + + def visit(value): + if isinstance(value, Capability): + if id(value) in seen: + return + seen.add(id(value)) + yield from value.entities + elif isinstance(value, (tuple, list, set)): + for child in value: + yield from visit(child) + + for value in vars(module).values(): + yield from visit(value) + + +def test_source_and_english_translation_are_identical(): + assert _load("strings.json") == _load("translations/en.json") + + +def test_english_and_dutch_catalog_topology_and_placeholders_match(): + english = _load("translations/en.json") + dutch = _load("translations/nl.json") + assert _topology(english) == _topology(dutch) + + dutch_strings = dict(_walk_strings(dutch)) + for path, value in _walk_strings(english): + assert _placeholders(value) == _placeholders(dutch_strings[path]), path + + +def test_every_translatable_descriptor_has_an_entity_catalog_entry(): + entity_strings = _load("strings.json")["entity"] + missing = [] + for desc in _all_descriptions(): + translation_key = desc.translation_key + if callable(translation_key): + # Runtime table resolvers use the generic name-only fallback plus + # the static tables currently documented by this integration. + 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)] + if translation_key not in entity_strings.get(platform, {}): + missing.append((platform, desc.key, translation_key)) + assert missing == [] + + select_strings = entity_strings["select"] + for key in ("cycle", "washer_cycle_table_02", "dryer_cycle_table_03"): + assert key in select_strings + + +def test_select_state_normalization_is_synchronized_with_catalog(): + select_strings = _load("strings.json")["entity"]["select"] + translated = { + key: frozenset(value["state"]) + for key, value in select_strings.items() + if "state" in value + } + assert TRANSLATED_SELECT_STATES == translated + + +def test_all_entity_state_translation_keys_are_lowercase(): + entity_strings = _load("strings.json")["entity"] + for platform in entity_strings.values(): + for translation in platform.values(): + for state_key in translation.get("state", {}): + assert state_key == state_key.lower() diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 8e2005f..f467072 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -64,16 +64,15 @@ class TestWasherCourse: """Table-scoped (issue: course codes aren't guaranteed consistent across board generations sharing /course/vs/0 -- FlexWash's older board reports Table_00, not the Table_02 every washer_cycle_table_02 - name was confirmed against) -- see laundry.cycle_select. The key is - built from whatever table the device reports, not gated against a - hardcoded 'known good' value.""" + name was confirmed against) -- see laundry.cycle_select. Only a + verified table gets table-specific state translations.""" desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') assert callable(desc.translation_key) table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} assert desc.translation_key(table_02) == 'washer_cycle_table_02' table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} - assert desc.translation_key(table_00) == 'washer_cycle_table_00' - assert desc.translation_key({}) is None + assert desc.translation_key(table_00) == 'cycle' + assert desc.translation_key({}) == 'cycle' def test_reads_raw_course_code_from_options_array(self): """rep_fn returns the raw device code; display names come from @@ -206,14 +205,11 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_quantity').rep_fn(rep) == '7' def test_translation_keys(self): - """detergent_quantity and softener_quantity share one translation_key - (same 00-03 -> None/Low/Medium/High vocabulary on both dispensers, - same shape as fridge.py's shared 'brightness_level' key); hardness - and concentration each have their own since their labels differ.""" - assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity' - assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity' - assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness' - assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration' + """Each entity gets its own translated name and state vocabulary.""" + assert self._desc('detergent_quantity').translation_key == 'detergent_quantity' + assert self._desc('softener_quantity').translation_key == 'softener_quantity' + assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness' + assert self._desc('softener_concentration').translation_key == 'softener_concentration' def test_quantity_and_hardness_options_decode_supported_list(self): assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03'] @@ -359,8 +355,11 @@ class TestWashOptionToggleValidation: def test_rejected_on_an_unsupported_course(self): rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} - msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES) - assert msg == "Bubble soak isn't available on the selected cycle." + issue = self._desc('bubble_soak').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES + ) + assert issue.translation_key == 'bubble_soak_unavailable_for_cycle' + assert issue.translation_placeholders == {} def test_pre_wash_and_intensive_use_their_own_availableset_field(self): rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} From da49825e2e3750e225af0d754ebbc758ba9b7372 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:01 +0200 Subject: [PATCH 4/8] Harden unknown vendor value fallbacks --- custom_components/localthings/entity.py | 6 +++++- custom_components/localthings/select.py | 10 ++++++++-- custom_components/localthings/strings.json | 3 +++ custom_components/localthings/translations/en.json | 3 +++ custom_components/localthings/translations/nl.json | 3 +++ tests/test_select_display.py | 2 +- tests/test_select_options.py | 2 +- 7 files changed, 24 insertions(+), 5 deletions(-) diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 84501b4..634f631 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -43,7 +43,11 @@ def _derive_name(state_key: str) -> str: Strips a trailing instance number of 0 (singleton), promotes any other instance number with a space: "door_cooler_open1" → "Door Cooler Open 1". """ - name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key) + name = re.sub( + r'_?(\d+)$', + lambda match: f' {match.group(1)}' if int(match.group(1)) > 0 else '', + state_key, + ) return _snake_to_title(name).strip() diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index c85cc95..2b0c62b 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -129,8 +129,14 @@ def _display(value, translation_key: Optional[str]): """ if not isinstance(value, str): return value - if translation_key and (translated := _translation_state(value, translation_key)): - return translated + if translation_key: + if translated := _translation_state(value, translation_key): + return translated + if translation_key not in TRANSLATED_SELECT_STATES: + # The entity name is translated, but this select deliberately has + # no static state catalog (for example an unknown course table). + # Keep its opaque device value untouched. + return value if value.islower(): return value.replace('_', ' ').title() return _CAMEL_BOUNDARY_RE.sub(' ', value) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 412cec0..c3860aa 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -488,6 +488,9 @@ "filter_progress": { "name": "Filter progress" }, + "filter_usage": { + "name": "Filter usage" + }, "fine_dust": { "name": "Fine dust" }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 412cec0..c3860aa 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -488,6 +488,9 @@ "filter_progress": { "name": "Filter progress" }, + "filter_usage": { + "name": "Filter usage" + }, "fine_dust": { "name": "Fine dust" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 05510d0..4998fdd 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -488,6 +488,9 @@ "filter_progress": { "name": "Filterverbruik" }, + "filter_usage": { + "name": "Filtergebruik" + }, "fine_dust": { "name": "Fijnstof" }, diff --git a/tests/test_select_display.py b/tests/test_select_display.py index 3476c9a..a3e5ee3 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -2,7 +2,7 @@ from custom_components.localthings.select import _display _UNTRANSLATED = None -_TRANSLATED = 'door_alert' +_TRANSLATED = 'ice_type' def test_display_titlecases_a_fully_lowercase_device_native_token(): diff --git a/tests/test_select_options.py b/tests/test_select_options.py index 6a64719..b318a95 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -49,7 +49,7 @@ def test_callable_options_receives_full_resource_snapshot(): '/other/vs/0': {'codes': ['1C', '1D']}, } entity = _make_select(desc, '/x/vs/0', resources) - assert entity.options == ['1c', '1d'] + assert entity.options == ['1C', '1D'] assert calls == [resources] From 82acc05d389f2f6799397b45548b81ad7b56a4a4 Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:40:44 +0200 Subject: [PATCH 5/8] Limit changes to translation support --- custom_components/localthings/__init__.py | 8 +++++--- custom_components/localthings/config_flow.py | 2 +- custom_components/localthings/coordinator.py | 17 ++++------------- custom_components/localthings/entity.py | 6 +----- .../localthings/registry/capabilities/washer.py | 4 ++-- .../localthings/registry/entities.py | 16 +++------------- tests/test_entity_naming.py | 7 ------- tests/test_washer_capabilities.py | 5 ++--- 8 files changed, 18 insertions(+), 47 deletions(-) diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 88a2ddb..1d35120 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -5,6 +5,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN, PLATFORMS from .coordinator import LocalThingsCoordinator @@ -15,9 +16,10 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) coordinator = LocalThingsCoordinator(hass, entry) - # DataUpdateCoordinator already raises ConfigEntryNotReady with the - # underlying update context; avoid replacing it with hardcoded UI text. - await coordinator.async_config_entry_first_refresh() + try: + await coordinator.async_config_entry_first_refresh() + except Exception as err: + raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index f773f9d..664f170 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -316,7 +316,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _create_entry(self, info: dict) -> FlowResult: return self.async_create_entry( - title=f"Samsung ({self._host})", + title=f"Samsung Appliance ({self._host})", data={ CONF_HOST: self._host, CONF_PORT: info["port"], diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 7a63d04..c00dcb1 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -24,7 +24,6 @@ from .registry.batch import parse_device0_batch from .registry.by_type import for_device, for_device_by_model, for_device_by_resources from .registry.capabilities.common import remote_control_enabled from .registry.discovery import discover, BoundEntity -from .registry.entities import ValidationIssue from .registry import CAPABILITIES from .registry.adapter import flatten from .registry.identity import read_identity, DeviceIdentity @@ -105,7 +104,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = entry.data[CONF_HOST] # placeholder until first poll self.device_info = DeviceInfo( identifiers={(DOMAIN, entry.data[CONF_HOST])}, - name=f"Samsung ({entry.data[CONF_HOST]})", + name=f"Samsung Appliance ({entry.data[CONF_HOST]})", manufacturer="Samsung", ) self._session_lock = asyncio.Lock() @@ -318,15 +317,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = serial ident = self._identity + device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance' model_num = info.get('x.com.samsung.da.modelNum', '') model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '') - # DeviceInfo has no translation mechanism. Prefer the appliance's own - # OCF name, then proper-noun/model fallbacks without an English family - # label. Existing device/entity registry IDs remain keyed by serial. - name = ( - (ident.name if ident else '') - or (f"Samsung {model}" if model else f"Samsung ({self._entry.data[CONF_HOST]})") - ) + name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" mfr = (ident.manufacturer if ident else '') or 'Samsung' self.device_info = DeviceInfo( @@ -555,12 +549,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if validate_fn is not None: error = validate_fn(payload, rep, resources) if error: - if not isinstance(error, ValidationIssue): - raise TypeError("validate_fn must return ValidationIssue or None") raise ServiceValidationError( translation_domain=DOMAIN, - translation_key=error.translation_key, - translation_placeholders=dict(error.translation_placeholders), + translation_key=error, ) result = write_fn(payload, rep, href) if result is None: diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 634f631..84501b4 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -43,11 +43,7 @@ def _derive_name(state_key: str) -> str: Strips a trailing instance number of 0 (singleton), promotes any other instance number with a space: "door_cooler_open1" → "Door Cooler Open 1". """ - name = re.sub( - r'_?(\d+)$', - lambda match: f' {match.group(1)}' if int(match.group(1)) > 0 else '', - state_key, - ) + name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key) return _snake_to_title(name).strip() diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 54ffaaa..006a5e1 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,7 +16,7 @@ array. from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, ValidationIssue +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc from .laundry import ( bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value, replace_in_options, @@ -282,7 +282,7 @@ def _bool_option_switch(key, name, icon, prefix, availability_field): if len(pairs) != len(courses): return None if pairs[courses.index(current)] != 'F0': - return ValidationIssue(f"{key}_unavailable_for_cycle") + return f"{key}_unavailable_for_cycle" return None return bool_option_switch( diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index 1325e1d..19a7ec8 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -8,25 +8,15 @@ where a description declares one. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Callable, Mapping, Optional WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] -# (payload, rep, resources) -> a translatable rejection, or None to allow the +# (payload, rep, resources) -> a translation key, or None to allow the # write. resources is the coordinator's full href->rep snapshot, for the same # cross-resource lookups exists_fn needs (e.g. reading a sibling href's live # option list). - - -@dataclass(frozen=True) -class ValidationIssue: - """A user-facing, translatable rejection from a descriptor validator.""" - - translation_key: str - translation_placeholders: Mapping[str, str] = field(default_factory=dict) - - -ValidateFn = Optional[Callable[[Any, dict, dict], "ValidationIssue | None"]] +ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] def _identity(v: Any) -> Any: diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py index 44b73d4..fa1aa5e 100644 --- a/tests/test_entity_naming.py +++ b/tests/test_entity_naming.py @@ -45,10 +45,3 @@ def test_href_instance_name_becomes_translation_placeholder(): ) entity = _make_entity(desc, key_override='icemaker_one_enabled') assert entity.translation_placeholders == {'instance_name': 'Icemaker One'} - - -def test_untranslated_vendor_entity_keeps_readable_fallback_name(): - desc = BinarySensorDesc(key='vendor_feature') - entity = _make_entity(desc, key_override='vendor_feature_1') - assert entity.translation_key is None - assert entity._attr_name == 'Vendor Feature 1' diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index f467072..b8655ce 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -355,11 +355,10 @@ class TestWashOptionToggleValidation: def test_rejected_on_an_unsupported_course(self): rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} - issue = self._desc('bubble_soak').validate_fn( + translation_key = self._desc('bubble_soak').validate_fn( 'On', rep, _EDIT_COURSE_RESOURCES ) - assert issue.translation_key == 'bubble_soak_unavailable_for_cycle' - assert issue.translation_placeholders == {} + assert translation_key == 'bubble_soak_unavailable_for_cycle' def test_pre_wash_and_intensive_use_their_own_availableset_field(self): rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} From 5d86dbe7e83c5bf8c005240dd999f642c1ab697f Mon Sep 17 00:00:00 2001 From: Hmmbob <33529490+hmmbob@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:13:07 +0200 Subject: [PATCH 6/8] Resolve runtime translation references --- .../localthings/translations/en.json | 86 +++++++++---------- .../localthings/translations/nl.json | 86 +++++++++---------- tests/test_translations.py | 22 ++++- 3 files changed, 106 insertions(+), 88 deletions(-) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c3860aa..b6a8dd2 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -20,7 +20,7 @@ "name": "Current limit enabled" }, "cycle_active": { - "name": "[%key:component::binary_sensor::entity_component::running::name%]" + "name": "Running" }, "defrost_active": { "name": "Defrost active" @@ -32,7 +32,7 @@ "name": "Device active" }, "door_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]" + "name": "Door" }, "firmware_update": { "name": "Firmware update available" @@ -73,16 +73,16 @@ "name": "Start diagnosis" }, "pause": { - "name": "[%key:common::action::pause%]" + "name": "Pause" }, "selfcheck_start": { "name": "Start self-check" }, "start": { - "name": "[%key:common::action::start%]" + "name": "Start" }, "stop": { - "name": "[%key:common::action::stop%]" + "name": "Stop" } }, "climate": { @@ -134,24 +134,24 @@ "brightness_level": { "name": "Night brightness", "state": { - "33": "[%key:common::state::low%]", - "66": "[%key:common::state::medium%]", - "100": "[%key:common::state::high%]" + "33": "Low", + "66": "Medium", + "100": "High" } }, "day_brightness": { "name": "Cabinet brightness", "state": { - "33": "[%key:common::state::low%]", - "66": "[%key:common::state::medium%]", - "100": "[%key:common::state::high%]" + "33": "Low", + "66": "Medium", + "100": "High" } }, "buzzer_sound": { "name": "Buzzer sound", "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "off": "Off", + "on": "On" } }, "cycle": { @@ -161,16 +161,16 @@ "name": "Detergent quantity", "state": { "00": "None", - "01": "[%key:common::state::low%]", - "02": "[%key:common::state::medium%]", - "03": "[%key:common::state::high%]" + "01": "Low", + "02": "Medium", + "03": "High" } }, "detergent_water_hardness": { "name": "Detergent water hardness", "state": { "01": "Soft", - "02": "[%key:common::state::medium%]", + "02": "Medium", "03": "Hard" } }, @@ -220,8 +220,8 @@ "finish_sound": { "name": "Finish sound", "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "off": "Off", + "on": "On" } }, "flex_zone_mode": { @@ -242,16 +242,16 @@ "heated_dry": { "name": "Smart Dry", "state": { - "off": "[%key:common::state::off%]", - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]", + "off": "Off", + "low": "Low", + "high": "High", "extra_high": "Extra high" } }, "ice_type": { "name": "{instance_name} type", "state": { - "off": "[%key:common::state::off%]", + "off": "Off", "whiskey_iceball_3": "3 balls/day", "whiskey_iceball_6": "6 balls/day", "whiskey_iceball_9": "9 balls/day" @@ -260,15 +260,15 @@ "led_brightness": { "name": "Door LED brightness", "state": { - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]" + "low": "Low", + "high": "High" } }, "led_night_brightness": { "name": "Door LED night brightness", "state": { - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]" + "low": "Low", + "high": "High" } }, "oven_mode": { @@ -297,7 +297,7 @@ "range_burner_power_level": { "name": "Burner {number} power level", "state": { - "0": "[%key:common::state::off%]", + "0": "Off", "boost": "Boost", "simmer": "Simmer" } @@ -324,9 +324,9 @@ "name": "Softener quantity", "state": { "00": "None", - "01": "[%key:common::state::low%]", - "02": "[%key:common::state::medium%]", - "03": "[%key:common::state::high%]" + "01": "Low", + "02": "Medium", + "03": "High" } }, "sound_mode": { @@ -342,9 +342,9 @@ "state": { "rinse_hold": "Rinse hold", "no_spin": "No spin", - "low": "[%key:common::state::low%]", - "medium": "[%key:common::state::medium%]", - "high": "[%key:common::state::high%]" + "low": "Low", + "medium": "Medium", + "high": "High" } }, "wash_temperature": { @@ -400,7 +400,7 @@ "120": "2 hr", "180": "3 hr", "240": "4 hr", - "none": "[%key:common::state::off%]", + "none": "Off", "cupboard": "Cupboard" } } @@ -441,7 +441,7 @@ "name": "Current limit level" }, "current_temp_c": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" + "name": "Temperature" }, "diagnosis": { "name": "Diagnosis" @@ -468,7 +468,7 @@ "name": "Dust" }, "energy_kwh": { - "name": "[%key:component::sensor::entity_component::energy::name%]" + "name": "Energy" }, "energy_last_month_kwh": { "name": "Energy (last month)" @@ -555,7 +555,7 @@ "name": "Power energy" }, "power_watts": { - "name": "[%key:component::sensor::entity_component::power::name%]" + "name": "Power" }, "progress": { "name": "Progress" @@ -581,15 +581,15 @@ "machine_state": { "name": "Machine state", "state": { - "idle": "[%key:common::state::idle%]", - "active": "[%key:common::state::active%]", + "idle": "Idle", + "active": "Active", "pause": "Paused" } }, "filter_status": { "name": "Filter status", "state": { - "normal": "[%key:common::state::normal%]", + "normal": "Normal", "wash": "Wash", "replace": "Replace" } @@ -597,7 +597,7 @@ "ice_making_status": { "name": "{instance_name} making status", "state": { - "icestatus_stop": "[%key:common::state::idle%]", + "icestatus_stop": "Idle", "icestatus_run": "Making ice" } } @@ -756,7 +756,7 @@ "unknown": "Unexpected error. Check the Home Assistant logs for details." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Device is already configured" } }, "options": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 4998fdd..e9957e1 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -20,7 +20,7 @@ "name": "Stroombegrenzing ingeschakeld" }, "cycle_active": { - "name": "[%key:component::binary_sensor::entity_component::running::name%]" + "name": "Actief" }, "defrost_active": { "name": "Ontdooien actief" @@ -32,7 +32,7 @@ "name": "Apparaat actief" }, "door_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]" + "name": "Deur" }, "firmware_update": { "name": "Firmware-update beschikbaar" @@ -73,16 +73,16 @@ "name": "Diagnose starten" }, "pause": { - "name": "[%key:common::action::pause%]" + "name": "Pauzeren" }, "selfcheck_start": { "name": "Zelfcontrole starten" }, "start": { - "name": "[%key:common::action::start%]" + "name": "Starten" }, "stop": { - "name": "[%key:common::action::stop%]" + "name": "Stoppen" } }, "climate": { @@ -134,24 +134,24 @@ "brightness_level": { "name": "Nachthelderheid", "state": { - "33": "[%key:common::state::low%]", - "66": "[%key:common::state::medium%]", - "100": "[%key:common::state::high%]" + "33": "Laag", + "66": "Gemiddeld", + "100": "Hoog" } }, "day_brightness": { "name": "Helderheid kast", "state": { - "33": "[%key:common::state::low%]", - "66": "[%key:common::state::medium%]", - "100": "[%key:common::state::high%]" + "33": "Laag", + "66": "Gemiddeld", + "100": "Hoog" } }, "buzzer_sound": { "name": "Zoemergeluid", "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "off": "Uit", + "on": "Aan" } }, "cycle": { @@ -161,16 +161,16 @@ "name": "Hoeveelheid wasmiddel", "state": { "00": "Geen", - "01": "[%key:common::state::low%]", - "02": "[%key:common::state::medium%]", - "03": "[%key:common::state::high%]" + "01": "Laag", + "02": "Gemiddeld", + "03": "Hoog" } }, "detergent_water_hardness": { "name": "Waterhardheid voor wasmiddel", "state": { "01": "Zacht", - "02": "[%key:common::state::medium%]", + "02": "Gemiddeld", "03": "Hard" } }, @@ -220,8 +220,8 @@ "finish_sound": { "name": "Eindgeluid", "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "off": "Uit", + "on": "Aan" } }, "flex_zone_mode": { @@ -242,16 +242,16 @@ "heated_dry": { "name": "Smart Dry", "state": { - "off": "[%key:common::state::off%]", - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]", + "off": "Uit", + "low": "Laag", + "high": "Hoog", "extra_high": "Extra hoog" } }, "ice_type": { "name": "Type {instance_name}", "state": { - "off": "[%key:common::state::off%]", + "off": "Uit", "whiskey_iceball_3": "3 ijsballen per dag", "whiskey_iceball_6": "6 ijsballen per dag", "whiskey_iceball_9": "9 ijsballen per dag" @@ -260,15 +260,15 @@ "led_brightness": { "name": "Helderheid deur-LED", "state": { - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]" + "low": "Laag", + "high": "Hoog" } }, "led_night_brightness": { "name": "Nachtelijke helderheid deur-LED", "state": { - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]" + "low": "Laag", + "high": "Hoog" } }, "oven_mode": { @@ -297,7 +297,7 @@ "range_burner_power_level": { "name": "Vermogensniveau brander {number}", "state": { - "0": "[%key:common::state::off%]", + "0": "Uit", "boost": "Boost", "simmer": "Sudderen" } @@ -324,9 +324,9 @@ "name": "Hoeveelheid wasverzachter", "state": { "00": "Geen", - "01": "[%key:common::state::low%]", - "02": "[%key:common::state::medium%]", - "03": "[%key:common::state::high%]" + "01": "Laag", + "02": "Gemiddeld", + "03": "Hoog" } }, "sound_mode": { @@ -342,9 +342,9 @@ "state": { "rinse_hold": "Spoelstop", "no_spin": "Niet centrifugeren", - "low": "[%key:common::state::low%]", - "medium": "[%key:common::state::medium%]", - "high": "[%key:common::state::high%]" + "low": "Laag", + "medium": "Gemiddeld", + "high": "Hoog" } }, "wash_temperature": { @@ -400,7 +400,7 @@ "120": "2 uur", "180": "3 uur", "240": "4 uur", - "none": "[%key:common::state::off%]", + "none": "Uit", "cupboard": "Kastdroog" } } @@ -441,7 +441,7 @@ "name": "Niveau stroombegrenzing" }, "current_temp_c": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" + "name": "Temperatuur" }, "diagnosis": { "name": "Diagnose" @@ -468,7 +468,7 @@ "name": "Stof" }, "energy_kwh": { - "name": "[%key:component::sensor::entity_component::energy::name%]" + "name": "Energie" }, "energy_last_month_kwh": { "name": "Energie (vorige maand)" @@ -555,7 +555,7 @@ "name": "Energieverbruik" }, "power_watts": { - "name": "[%key:component::sensor::entity_component::power::name%]" + "name": "Vermogen" }, "progress": { "name": "Voortgang" @@ -581,15 +581,15 @@ "machine_state": { "name": "Machinestatus", "state": { - "idle": "[%key:common::state::idle%]", - "active": "[%key:common::state::active%]", + "idle": "Inactief", + "active": "Actief", "pause": "Gepauzeerd" } }, "filter_status": { "name": "Filterstatus", "state": { - "normal": "[%key:common::state::normal%]", + "normal": "Normaal", "wash": "Reinigen", "replace": "Vervangen" } @@ -597,7 +597,7 @@ "ice_making_status": { "name": "{instance_name} ijsmaakstatus", "state": { - "icestatus_stop": "[%key:common::state::idle%]", + "icestatus_stop": "Inactief", "icestatus_run": "IJs maken" } } @@ -756,7 +756,7 @@ "unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Apparaat is al geconfigureerd" } }, "options": { diff --git a/tests/test_translations.py b/tests/test_translations.py index ab94b81..c5bb553 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -72,8 +72,26 @@ def _all_descriptions(): yield from visit(value) -def test_source_and_english_translation_are_identical(): - assert _load("strings.json") == _load("translations/en.json") +def test_source_and_english_translation_topology_and_placeholders_match(): + source = _load("strings.json") + english = _load("translations/en.json") + assert _topology(source) == _topology(english) + + english_strings = dict(_walk_strings(english)) + for path, value in _walk_strings(source): + assert _placeholders(value) == _placeholders(english_strings[path]), path + + +def test_runtime_translation_catalogs_have_no_unresolved_references(): + for language in ("en", "nl"): + unresolved = [ + (path, value) + for path, value in _walk_strings( + _load(f"translations/{language}.json") + ) + if "[%key:" in value + ] + assert unresolved == [] def test_english_and_dutch_catalog_topology_and_placeholders_match(): From 6281b40549fd3d95a301aa7c47fd2ba852081bdd Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 19:32:09 +0000 Subject: [PATCH 7/8] refactor(i18n): make the shipped catalog the single source of truth PR #68 restated its own translation data in Python: a 60-line TRANSLATED_SELECT_STATES table of frozensets duplicating every entity.select.*.state key, a second _TRANSLATED_COURSE_TABLES table naming which course tables have translations, and a strings.json that was a 835-line byte-for-byte copy of translations/en.json save 43 [%key:...%] references. Each needed hand-syncing, and one was already drifting. Home Assistant loads exactly one file per language for a custom integration -- translations/.json. It never reads strings.json and never resolves [%key:...%]; both belong to Core's build tooling, which custom integrations don't run through (hassfest skips a missing strings.json and validates translations/en.json instead). So en.json is the source, and the new catalog.py reads the keys and states back out of it for the two decisions Python genuinely has to make: - select._display() normalizes a raw Samsung option to a lowercase state key only when the catalog knows it, else leaves the vendor's casing alone. Derived sets are identical to the removed literals. - laundry.cycle_select() keys off a device-reported course table only when that table has an entry, else falls back to the name-only 'cycle' key. Translating Table_00 is now a translations-only change. Also fixes six names that had already drifted between the Python descriptors and the catalog, restoring HA's sentence case for two generic ones (Auto release dry, Bubble soak) and taking the catalog's wording for the rest, and adds a test so the vestigial descriptor names can't silently disagree with the UI again. Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp --- .claude/skills/adding-device-support/SKILL.md | 7 +- README.md | 5 +- custom_components/localthings/catalog.py | 49 + .../registry/capabilities/common.py | 6 +- .../registry/capabilities/fridge.py | 2 +- .../registry/capabilities/laundry.py | 26 +- .../localthings/registry/capabilities/oven.py | 2 +- .../registry/capabilities/washer.py | 10 +- custom_components/localthings/select.py | 94 +- custom_components/localthings/strings.json | 835 ------------------ .../localthings/translations/en.json | 4 +- tests/test_common_capabilities.py | 2 +- tests/test_select_display.py | 2 +- tests/test_translations.py | 111 ++- tests/test_washer_capabilities.py | 2 +- 15 files changed, 179 insertions(+), 978 deletions(-) create mode 100644 custom_components/localthings/catalog.py delete mode 100644 custom_components/localthings/strings.json diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 14a0c85..2e68b0d 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -109,10 +109,15 @@ 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 **both** `strings.json` and `translations/en.json` under +- 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. ## 6. Coverage discipline: bound or ignored diff --git a/README.md b/README.md index cc0e27f..4e401d5 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,10 @@ custom_components/localthings/ entity.py Base entity wiring capability registry -> HA entity sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py One module per HA platform - strings.json / translations/ Config-flow copy + entity state translations + catalog.py Reads the shipped translation catalog (which keys/states exist) + translations/ Config-flow copy + entity name/state translations, one file per + language; en.json is the source of truth (no strings.json — + Home Assistant never reads one from a custom integration) registry/ registry.py Builds the global capability registry, validates href collisions capability.py Capability dataclass (href, entities, transforms) diff --git a/custom_components/localthings/catalog.py b/custom_components/localthings/catalog.py new file mode 100644 index 0000000..02d7616 --- /dev/null +++ b/custom_components/localthings/catalog.py @@ -0,0 +1,49 @@ +"""The integration's own shipped translation catalog, as data. + +Home Assistant loads exactly one file per language for a custom integration: +``translations/.json``. It does not read ``strings.json`` and it does +not resolve ``[%key:...%]`` references -- both of those belong to Core's +build tooling (``script/translations``), which custom integrations never run +through. So ``translations/en.json`` is not a generated artifact here, it is +the source of truth, and English is the one catalog required to be complete +(hassfest validates it for custom integrations). + +That makes the English catalog the authority on *which* translation keys and +states exist, which is something the Python side genuinely needs to know: + + * ``select`` has to decide whether to normalize a raw Samsung option into + a lowercase state key (so Home Assistant can look it up) or leave the + vendor's own casing alone as a readable fallback. + * ``laundry.cycle_select`` has to decide whether a device-reported course + table has translations at all before keying off it. + +Reading it from the catalog instead of restating it in Python means adding a +state or a course table is a one-file change, and the two can't drift. +""" +from __future__ import annotations + +import json +from pathlib import Path + +# Read at import, not lazily: custom integrations are imported in an executor +# thread, so this stays off the event loop no matter who asks first. +_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads( + (Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8') +).get('entity', {}) + + +def has_entity_translation(platform: str, translation_key: str) -> bool: + """Whether `platform`.`translation_key` has an entry in the catalog.""" + return translation_key in _ENTITY_CATALOG.get(platform, {}) + + +def translated_states(platform: str, translation_key: str) -> frozenset[str]: + """The state keys translated for `platform`.`translation_key`. + + Empty when the entity has no translation at all, and also when it has a + translated *name* but deliberately no state table (a course list whose + codes we can't map, for instance) -- callers treat both the same way: + leave the device's value untouched. + """ + entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key) + return frozenset(entry.get('state', ())) if entry else frozenset() diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index bcf06f3..7590328 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -301,7 +301,7 @@ WATER_FILTER = Capability( # # No translation_key: aiLevel's values are plain digit strings, and # select.py's _display() already renders an untranslated numeric string -# as-is -- there's nothing a strings.json entry adds that's worth maintaining +# as-is -- there's nothing a catalog entry adds that's worth maintaining # against an unknown, growing number of future levels. @@ -348,14 +348,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 level', icon='mdi:leaf', + name='AI Energy Mode', 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 level', icon='mdi:leaf', + name='AI Energy Mode level', icon='mdi:leaf', entity_category='config', options=_ai_energy_level_options, exists_fn=lambda rep, resources: ( diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 003e364..6967b5b 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -25,7 +25,7 @@ from ..entities import ( from .common import normalize_temp_unit # Display names for the beverage zone, flex zone, ice type, and -# ice-making-status enums below live in strings.json / translations/en.json, +# ice-making-status enums below live in translations/en.json, # keyed by the lowercased raw device value — select.py and SensorDesc.options # normalize to lowercase for HA's translation lookup and map back to this # original casing before writing to the device. diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index afec8e3..943a18d 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -21,6 +21,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` / """ from datetime import time as dt_time +from ...catalog import has_entity_translation from ..capability import Capability from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc @@ -276,12 +277,6 @@ def _table_id(resources, table_href): return rep.get('x.com.samsung.da.st.courseTable') -_TRANSLATED_COURSE_TABLES = { - 'dryer_cycle': frozenset({'table_03'}), - 'washer_cycle': frozenset({'table_02'}), -} - - def cycle_select(*, translation_key, icon, table_href=None): """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. @@ -302,10 +297,13 @@ def cycle_select(*, translation_key, icon, table_href=None): washer_cycle_table_02 was confirmed against Table_02-reporting devices (DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports Table_00 instead, so the same hex code could mean a different course - there for all we've verified. Only tables with a complete catalog are - assigned a state translation key. Unknown future tables deliberately use - the generic key, avoiding misleading translations and missing-translation - errors while keeping the exact vendor code for writes. + there for all we've verified. So a table-specific key is used only when + the shipped catalog actually has one; any other table (Table_00 today, + whatever ships next) falls back to the name-only ``cycle`` key, which + shows the raw course code rather than a label borrowed from another + board generation. Translating a new table is therefore a + translations-only change -- add the ``_cycle_`` entry and + this resolver picks it up. Left at its default for dishwasher, which has no equivalent table-id resource in any dump seen and no evidence its course codes vary by @@ -316,10 +314,10 @@ def cycle_select(*, translation_key, icon, table_href=None): if table_href is not None: def key(resources): table = _table_id(resources, table_href) - table_id = table.lower() if isinstance(table, str) else None - if table_id in _TRANSLATED_COURSE_TABLES.get(translation_key, ()): - return f'{translation_key}_{table_id}' - return 'cycle' + if not isinstance(table, str) or not table: + return 'cycle' + candidate = f'{translation_key}_{table.lower()}' + return candidate if has_entity_translation('select', candidate) else 'cycle' return SelectDesc( key='cycle', name='Cycle', icon=icon, translation_key=key, diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index b8d8ea8..5da0397 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -239,7 +239,7 @@ OVEN_OPERATIONAL_STATE = Capability( value_fn=_int), SensorDesc(key='operation_time_minutes', field='x.com.samsung.da.operationTime', - name='Operation time (minutes)', unit='min', + name='Operation time', 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', diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 006a5e1..37d3917 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -23,8 +23,8 @@ from .laundry import ( ) # --------------------------------------------------------------------------- -# Course_XX hex codes. 23 of the codes named in strings.json/translations -# under entity.select.washer_cycle.state. were captured +# Course_XX hex codes. 23 of the codes named in translations/en.json +# under entity.select.washer_cycle_table_02.state. were captured # from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList # (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched # positionally against a Slovak-UI user's screenshots of their app's course @@ -157,8 +157,8 @@ def _drum_clean_last_cleaned(rep): # valid raw codes for its field, same hex-pair shape as EditCourseList. # 'Alarm_' is a low-reservoir warning flag. # -# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_ -# water_hardness/washer_softener_concentration in strings.json) is an +# Label mapping (entity.select.{detergent,softener}_quantity / +# detergent_water_hardness / softener_concentration in translations/en.json) is an # assumed, not cross-device-verified, reading of the single issue #9 dump + # screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no # on-screen equivalent -- the app's Quantité picker only offers @@ -349,7 +349,7 @@ WASHER_COURSE = Capability( rep_fn=_dosing_low('SoftenerAlarm')), _bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble', 'BubbleSoak', 'BubbleSoakSet'), - _bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine', + _bool_option_switch('pre_wash', 'Pre-wash', 'mdi:washing-machine', 'PreWashSetting', 'PreWashAvailableSet'), _bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine', 'IntensiveSetting', 'IntensiveAvailableSet'), diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 2b0c62b..d7ed8fe 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .registry.entities import SelectDesc +from .catalog import translated_states from .const import DOMAIN from .coordinator import LocalThingsCoordinator from .entity import LocalThingsEntity, _is_included @@ -31,73 +32,16 @@ async def async_setup_entry( _CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])') -# Keep this synchronized with entity.select.*.state in strings.json. A select -# can have a translation key for its name without translating its dynamic -# Samsung options. Only known states are normalized for HA's lowercase lookup; -# unknown future vendor values retain a readable label and exact write value. -TRANSLATED_SELECT_STATES: dict[str, frozenset[str]] = { - 'beverage_zone_mode': frozenset({ - 'sp_ttype_beer_drinks', 'sp_ttype_wine_dessert', - }), - 'brightness_level': frozenset({'33', '66', '100'}), - 'day_brightness': frozenset({'33', '66', '100'}), - 'dishwasher_cycle': frozenset({ - '07', '0e', '80', '83', '84', '86', '8d', '8e', '8f', '90', - }), - 'door_alert': frozenset({'1', '2', '3', '4'}), - 'dryer_cycle_table_03': frozenset({ - '16', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', '20', - '23', '24', '25', '27', - }), - 'finish_sound': frozenset({'off', 'on'}), - 'flex_zone_mode': frozenset({ - 'cv_fdr_beverage', 'cv_fdr_deli', 'cv_fdr_meat', - 'cv_fdr_soft_freezer', 'cv_fdr_wine', - 'cv_ttype_rf9000a_beverage', 'cv_ttype_rf9000a_freeze', - 'cv_ttype_rf9000a_fruit_veggies', - 'cv_ttype_rf9000a_meat_fish', 'cv_ttype_rf9000a_softfreeze', - }), - 'heated_dry': frozenset({'extra_high', 'high', 'low', 'off'}), - 'ice_type': frozenset({ - 'off', 'whiskey_iceball_3', 'whiskey_iceball_6', - 'whiskey_iceball_9', - }), - 'led_brightness': frozenset({'high', 'low'}), - 'led_night_brightness': frozenset({'high', 'low'}), - 'oven_mode': frozenset({ - 'air_fry', 'bake', 'broil', 'convection', 'convection_bake', - 'convection_broil', 'frozen_pizza_plus', 'no_operation', - 'plate_warm', 'slow_cook', - }), - 'pantry_zone_mode': frozenset({'fdr_deli', 'fdr_drinks', 'fdr_wine'}), - 'range_burner_power_level': frozenset({'0', 'boost', 'simmer'}), - 'sound_mode': frozenset({'mute', 'tone', 'voice'}), - 'spin_speed': frozenset({'high', 'low', 'medium', 'no_spin', 'rinse_hold'}), - 'buzzer_sound': frozenset({'off', 'on'}), - 'wash_temperature': frozenset({ - 'cold', 'cool', 'extra_hot', 'hot', 'none', 'warm', - }), - 'washer_cycle_table_02': frozenset({ - '1b', '1c', '1d', '1e', '1f', '20', '21', '22', '23', '24', - '25', '26', '27', '28', '29', '2d', '2e', '2f', '30', '32', - '33', '36', '37', '38', '39', '66', '8f', '96', - }), - 'detergent_quantity': frozenset({'00', '01', '02', '03'}), - 'softener_quantity': frozenset({'00', '01', '02', '03'}), - 'detergent_water_hardness': frozenset({'01', '02', '03'}), - 'softener_concentration': frozenset({'01', '02', '03'}), - 'washer_dry_level': frozenset({ - '30', '60', '90', '120', '180', '240', 'cupboard', 'none', - }), - 'range_hood_lamp_brightness': frozenset({'1', '2'}), -} +def _translation_state(value: str, known: frozenset[str]) -> str | None: + """Return the catalog state `value` normalizes to, else None. -def _translation_state(value: str, translation_key: str) -> str | None: - """Return a known HA translation state, or None for a vendor fallback.""" - known = TRANSLATED_SELECT_STATES.get(translation_key) - if known is None: - return None + Samsung reports options in whatever casing the resource uses + ('Rinse_Hold', 'SpTtypeBeerDrinks', '1b'); Home Assistant looks state + translations up by a lowercase key. Only values the catalog actually + knows are normalized -- an unrecognized (or future) vendor value keeps + its own readable form rather than becoming an untranslatable slug. + """ direct = value.lower().replace(' ', '_') if direct in known: return direct @@ -113,13 +57,13 @@ def _display(value, translation_key: Optional[str]): callers pass the resolved value, e.g. self.translation_key, not the raw descriptor field). - An entity with a translation_key looks its state up in strings.json, - and hassfest requires those keys to be lowercase -- so those values + An entity with a translation_key looks its state up in the shipped + translation catalog, whose state keys are lowercase -- so those values must be lowercased exactly to match, and the device still expects that same raw casing back on write (callers map the displayed value back to raw via _raw_options()). - Everything else has no strings.json lookup, so there's no reason to + Everything else has no catalog lookup, so there's no reason to destroy the device's own casing. Only two cosmetic fixups apply: a fully lowercase device-native token (e.g. "voice") is title-cased, and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at @@ -130,13 +74,15 @@ def _display(value, translation_key: Optional[str]): if not isinstance(value, str): return value if translation_key: - if translated := _translation_state(value, translation_key): - return translated - if translation_key not in TRANSLATED_SELECT_STATES: - # The entity name is translated, but this select deliberately has - # no static state catalog (for example an unknown course table). - # Keep its opaque device value untouched. + known = translated_states('select', translation_key) + if not known: + # No state table for this key: either the entity isn't translated + # at all, or its name is translated but its options deliberately + # aren't (an unrecognized course table, say). Either way the + # opaque device value is the best thing to show. return value + if translated := _translation_state(value, known): + return translated if value.islower(): return value.replace('_', ' ').title() return _CAMEL_BOUNDARY_RE.sub(' ', value) diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json deleted file mode 100644 index c3860aa..0000000 --- a/custom_components/localthings/strings.json +++ /dev/null @@ -1,835 +0,0 @@ -{ - "entity": { - "binary_sensor": { - "any_burner_active": { - "name": "Any burner active" - }, - "automatic_operation": { - "name": "Automatic operation" - }, - "burner_hot_surface": { - "name": "Burner {number} hot surface" - }, - "cloud_connected": { - "name": "Cloud connected" - }, - "cooktop_safety_shutoff_enabled": { - "name": "Hot surface auto-shutoff enabled" - }, - "current_limit_enabled": { - "name": "Current limit enabled" - }, - "cycle_active": { - "name": "[%key:component::binary_sensor::entity_component::running::name%]" - }, - "defrost_active": { - "name": "Defrost active" - }, - "detergent_low": { - "name": "Detergent low" - }, - "device_active": { - "name": "Device active" - }, - "door_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]" - }, - "firmware_update": { - "name": "Firmware update available" - }, - "instance_open": { - "name": "{instance_name} open" - }, - "paired_hood_connected": { - "name": "Paired hood connected" - }, - "paired_hood_light": { - "name": "Paired hood light" - }, - "paired_hood_power": { - "name": "Paired hood power" - }, - "periodic_air_sensing": { - "name": "Periodic air sensing" - }, - "power_state": { - "name": "Power state" - }, - "rapid_freezing": { - "name": "Rapid freezing" - }, - "rapid_fridge": { - "name": "Rapid fridge" - }, - "remote_control": { - "name": "Smart Control" - }, - "softener_low": { - "name": "Softener low" - } - }, - "button": { - "diagnosis_start": { - "name": "Start diagnosis" - }, - "pause": { - "name": "[%key:common::action::pause%]" - }, - "selfcheck_start": { - "name": "Start self-check" - }, - "start": { - "name": "[%key:common::action::start%]" - }, - "stop": { - "name": "[%key:common::action::stop%]" - } - }, - "climate": { - "airconditioner": { - "state_attributes": { - "fan_mode": { - "state": { - "turbo": "Turbo" - } - }, - "preset_mode": { - "state": { - "quiet": "Quiet", - "smart": "Smart", - "speed": "Speed" - } - } - } - } - }, - "number": { - "cook_time": { - "name": "Cook time" - }, - "delay_start_hours": { - "name": "Delay start" - }, - "instance_setpoint": { - "name": "{instance_name} setpoint" - }, - "oven_setpoint": { - "name": "Setpoint" - }, - "sound_volume": { - "name": "Sound volume" - } - }, - "select": { - "ai_energy_level": { - "name": "AI Energy Mode level" - }, - "beverage_zone_mode": { - "name": "Beverage zone mode", - "state": { - "sp_ttype_beer_drinks": "Beverage", - "sp_ttype_wine_dessert": "Wine and dessert" - } - }, - "brightness_level": { - "name": "Night brightness", - "state": { - "33": "[%key:common::state::low%]", - "66": "[%key:common::state::medium%]", - "100": "[%key:common::state::high%]" - } - }, - "day_brightness": { - "name": "Cabinet brightness", - "state": { - "33": "[%key:common::state::low%]", - "66": "[%key:common::state::medium%]", - "100": "[%key:common::state::high%]" - } - }, - "buzzer_sound": { - "name": "Buzzer sound", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" - } - }, - "cycle": { - "name": "Cycle" - }, - "detergent_quantity": { - "name": "Detergent quantity", - "state": { - "00": "None", - "01": "[%key:common::state::low%]", - "02": "[%key:common::state::medium%]", - "03": "[%key:common::state::high%]" - } - }, - "detergent_water_hardness": { - "name": "Detergent water hardness", - "state": { - "01": "Soft", - "02": "[%key:common::state::medium%]", - "03": "Hard" - } - }, - "dishwasher_cycle": { - "name": "Cycle", - "state": { - "80": "Delicate", - "83": "Express 60", - "84": "Heavy", - "86": "Normal", - "90": "Self clean", - "0e": "AI Wash", - "07": "Pre blast", - "8d": "Pots and pans", - "8e": "Plastic", - "8f": "Baby Care" - } - }, - "door_alert": { - "name": "Door alarm", - "state": { - "1": "Alarm 1", - "2": "Alarm 2", - "3": "Alarm 3", - "4": "Alarm 4" - } - }, - "dryer_cycle_table_03": { - "name": "Cycle", - "state": { - "16": "Cotton", - "18": "Synthetics", - "19": "Delicates", - "20": "Iron dry", - "23": "Quick Dry 35", - "24": "Cool air", - "25": "Warm air", - "27": "Time dry", - "1a": "Wool", - "1b": "Bedding", - "1c": "Shirts", - "1d": "Towels", - "1e": "Outdoor", - "1f": "Mixed load" - } - }, - "finish_sound": { - "name": "Finish sound", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" - } - }, - "flex_zone_mode": { - "name": "Flex zone mode", - "state": { - "cv_ttype_rf9000a_freeze": "Freeze", - "cv_ttype_rf9000a_softfreeze": "Soft freeze", - "cv_ttype_rf9000a_meat_fish": "Meat/Fish", - "cv_ttype_rf9000a_fruit_veggies": "Fruit & vegetables", - "cv_ttype_rf9000a_beverage": "Beverage", - "cv_fdr_wine": "Wine", - "cv_fdr_deli": "Deli", - "cv_fdr_beverage": "Beverage", - "cv_fdr_meat": "Meat", - "cv_fdr_soft_freezer": "Soft freezer" - } - }, - "heated_dry": { - "name": "Smart Dry", - "state": { - "off": "[%key:common::state::off%]", - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]", - "extra_high": "Extra high" - } - }, - "ice_type": { - "name": "{instance_name} type", - "state": { - "off": "[%key:common::state::off%]", - "whiskey_iceball_3": "3 balls/day", - "whiskey_iceball_6": "6 balls/day", - "whiskey_iceball_9": "9 balls/day" - } - }, - "led_brightness": { - "name": "Door LED brightness", - "state": { - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]" - } - }, - "led_night_brightness": { - "name": "Door LED night brightness", - "state": { - "low": "[%key:common::state::low%]", - "high": "[%key:common::state::high%]" - } - }, - "oven_mode": { - "name": "Cooking mode", - "state": { - "no_operation": "No operation", - "bake": "Bake", - "broil": "Broil", - "convection": "Convection", - "convection_bake": "Convection bake", - "convection_broil": "Convection broil", - "frozen_pizza_plus": "Frozen Pizza+", - "slow_cook": "Slow cook", - "plate_warm": "Plate warm", - "air_fry": "Air fry" - } - }, - "pantry_zone_mode": { - "name": "Pantry zone mode", - "state": { - "fdr_wine": "Wine", - "fdr_deli": "Deli", - "fdr_drinks": "Drinks" - } - }, - "range_burner_power_level": { - "name": "Burner {number} power level", - "state": { - "0": "[%key:common::state::off%]", - "boost": "Boost", - "simmer": "Simmer" - } - }, - "range_hood_lamp_brightness": { - "name": "Lamp brightness", - "state": { - "1": "Level 1", - "2": "Level 2" - } - }, - "rinse_cycles": { - "name": "Rinse cycles" - }, - "softener_concentration": { - "name": "Softener concentration", - "state": { - "01": "1x", - "02": "2x", - "03": "3x" - } - }, - "softener_quantity": { - "name": "Softener quantity", - "state": { - "00": "None", - "01": "[%key:common::state::low%]", - "02": "[%key:common::state::medium%]", - "03": "[%key:common::state::high%]" - } - }, - "sound_mode": { - "name": "Sound mode", - "state": { - "voice": "Voice", - "tone": "Tone", - "mute": "Mute" - } - }, - "spin_speed": { - "name": "Spin speed", - "state": { - "rinse_hold": "Rinse hold", - "no_spin": "No spin", - "low": "[%key:common::state::low%]", - "medium": "[%key:common::state::medium%]", - "high": "[%key:common::state::high%]" - } - }, - "wash_temperature": { - "name": "Wash temperature", - "state": { - "none": "None", - "cold": "Cold", - "cool": "Cool", - "warm": "Warm", - "hot": "Hot", - "extra_hot": "Extra hot" - } - }, - "washer_cycle_table_02": { - "name": "Cycle", - "state": { - "20": "Hygiene Steam", - "21": "Colours", - "22": "Wool", - "23": "Outdoor", - "24": "Towels", - "25": "Synthetics", - "26": "Delicates", - "27": "Rinse+Spin", - "28": "Drain/Spin", - "29": "Drum Clean+", - "30": "Cloudy Day", - "32": "Shirts", - "33": "Bedding", - "36": "Wash+Dry", - "37": "Air Wash", - "38": "Cotton Dry", - "39": "Synthetics Dry", - "66": "Denim", - "96": "Less Microfiber", - "1c": "Eco 40-60", - "1d": "Super Speed", - "1b": "Cotton", - "1e": "15' Quick Wash", - "2f": "Activewear", - "2e": "Baby Care", - "2d": "Silent Wash", - "8f": "Intense Cold", - "1f": "Intense Cold" - } - }, - "washer_dry_level": { - "name": "Dry level", - "state": { - "30": "30 min", - "60": "1 hr", - "90": "1 hr 30", - "120": "2 hr", - "180": "3 hr", - "240": "4 hr", - "none": "[%key:common::state::off%]", - "cupboard": "Cupboard" - } - } - }, - "sensor": { - "air_filter_usage": { - "name": "Filter usage" - }, - "air_sensing_state": { - "name": "Air sensing state" - }, - "alarm_code": { - "name": "Alarm code" - }, - "auto_ventilation_action": { - "name": "Auto ventilation action" - }, - "automatic_ventilation_state": { - "name": "Automatic ventilation state" - }, - "burner_state": { - "name": "Burner {number} state" - }, - "clean_level": { - "name": "Clean level" - }, - "connection_mode": { - "name": "Connection mode", - "state": { - "observe": "Push (observe)", - "poll": "Polling" - } - }, - "cooktop_state": { - "name": "Cooktop state" - }, - "current_limit_level": { - "name": "Current limit level" - }, - "current_temp_c": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "diagnosis": { - "name": "Diagnosis" - }, - "diagnosis_status": { - "name": "Diagnosis status" - }, - "drum_clean_cycles_remaining": { - "name": "Drum clean due in" - }, - "drum_clean_last_cleaned": { - "name": "Drum last cleaned" - }, - "dry_level": { - "name": "Dry level" - }, - "dry_time": { - "name": "Dry time" - }, - "dryer_type": { - "name": "Dryer type" - }, - "dust": { - "name": "Dust" - }, - "energy_kwh": { - "name": "[%key:component::sensor::entity_component::energy::name%]" - }, - "energy_last_month_kwh": { - "name": "Energy (last month)" - }, - "energy_saved_kwh": { - "name": "Energy saved" - }, - "energy_this_month_kwh": { - "name": "Energy (this month)" - }, - "fan_direction": { - "name": "Fan direction" - }, - "fan_speed_level": { - "name": "Fan speed level" - }, - "filter_progress": { - "name": "Filter progress" - }, - "filter_usage": { - "name": "Filter usage" - }, - "fine_dust": { - "name": "Fine dust" - }, - "finish_time": { - "name": "Estimated finish" - }, - "freezer_temperature": { - "name": "Freezer temperature" - }, - "fridge_temperature": { - "name": "Fridge temperature" - }, - "hood_filter_capacity": { - "name": "Filter capacity" - }, - "hood_filter_usage": { - "name": "Filter usage" - }, - "instance_temperature": { - "name": "{instance_name} temperature" - }, - "job_beginning_status": { - "name": "Job beginning status" - }, - "last_air_sensing_level": { - "name": "Last air sensing level" - }, - "last_air_sensing_time": { - "name": "Last air sensing time" - }, - "main_timer_current": { - "name": "Timer current value" - }, - "main_timer_state": { - "name": "Timer state" - }, - "odor": { - "name": "Odor" - }, - "operating_mode": { - "name": "Operating mode" - }, - "operation_origin": { - "name": "Last operation source" - }, - "operation_time_minutes": { - "name": "Operation time" - }, - "oven_state": { - "name": "Cavity state" - }, - "paired_hood_fan_speed": { - "name": "Paired hood fan speed" - }, - "paired_hood_firmware": { - "name": "Paired hood firmware" - }, - "paired_hood_model": { - "name": "Paired hood model" - }, - "power_energy_kwh": { - "name": "Power energy" - }, - "power_watts": { - "name": "[%key:component::sensor::entity_component::power::name%]" - }, - "progress": { - "name": "Progress" - }, - "progress_percentage": { - "name": "Progress percent" - }, - "selfcheck_error": { - "name": "Self-check error" - }, - "selfcheck_result": { - "name": "Self-check result" - }, - "selfcheck_status": { - "name": "Self-check status" - }, - "super_fine_dust": { - "name": "Super fine dust" - }, - "water_liters": { - "name": "Water consumption" - }, - "machine_state": { - "name": "Machine state", - "state": { - "idle": "[%key:common::state::idle%]", - "active": "[%key:common::state::active%]", - "pause": "Paused" - } - }, - "filter_status": { - "name": "Filter status", - "state": { - "normal": "[%key:common::state::normal%]", - "wash": "Wash", - "replace": "Replace" - } - }, - "ice_making_status": { - "name": "{instance_name} making status", - "state": { - "icestatus_stop": "[%key:common::state::idle%]", - "icestatus_run": "Making ice" - } - } - }, - "switch": { - "ai_energy_level": { - "name": "AI Energy Mode" - }, - "air_purify": { - "name": "Air purification" - }, - "auto_clean": { - "name": "Auto clean" - }, - "auto_door_opener": { - "name": "Auto door opener" - }, - "auto_release_dry": { - "name": "Auto Release Dry" - }, - "autofill": { - "name": "Autofill pitcher" - }, - "bubble_soak": { - "name": "Bubble Soak" - }, - "cabinet_light_dim": { - "name": "Brighten gradually" - }, - "cabinet_light_switch": { - "name": "Cabinet light" - }, - "child_lock": { - "name": "Child lock" - }, - "defrost_delay": { - "name": "Defrost delay" - }, - "display_light": { - "name": "Display light" - }, - "fast_preheat": { - "name": "Fast preheat" - }, - "fridge_sound": { - "name": "Sound" - }, - "ice_maker_enabled": { - "name": "Ice maker" - }, - "ice_night_mode": { - "name": "Nighttime ice quiet mode" - }, - "instance_enabled": { - "name": "{instance_name} enabled" - }, - "intensive": { - "name": "Intensive" - }, - "lamp": { - "name": "Lamp" - }, - "led_night_light": { - "name": "Door LED night light" - }, - "mute_once": { - "name": "Mute once" - }, - "natural_steam": { - "name": "Natural steam" - }, - "power_switch": { - "name": "Power" - }, - "pre_wash": { - "name": "Pre-wash" - }, - "rapid_freezing": { - "name": "Rapid freezing" - }, - "rapid_fridge": { - "name": "Rapid fridge" - }, - "sabbath_mode": { - "name": "Sabbath mode" - }, - "sanitize": { - "name": "Sanitize" - }, - "sound": { - "name": "Sound" - }, - "storm_wash": { - "name": "Storm Wash+" - }, - "welcome_lighting": { - "name": "Welcome lighting" - }, - "wrinkle_prevent": { - "name": "Wrinkle prevent" - } - }, - "time": { - "led_night_end": { - "name": "Door LED night end" - }, - "led_night_start": { - "name": "Door LED night start" - }, - "night_end": { - "name": "Night light end" - }, - "night_start": { - "name": "Night light start" - } - } - }, - "config": { - "step": { - "user": { - "title": "Add Samsung appliance", - "description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates.", - "data": { - "host": "IP address", - "ca_cert_pem": "CA certificate (PEM)", - "ca_key_pem": "CA private key (PEM)" - }, - "data_description": { - "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).", - "ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.", - "ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer." - } - }, - "user_reuse": { - "title": "Add Samsung appliance", - "description": "Enter the IP address of your Samsung appliance. The CA credentials from an existing LocalThings appliance will be reused.", - "data": { - "host": "IP address" - }, - "data_description": { - "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50)." - } - }, - "confirm_unknown_type": { - "title": "Appliance type not recognized", - "description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." - }, - "confirm_unknown_type_no_version": { - "title": "Appliance type not recognized", - "description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." - } - }, - "error": { - "cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.", - "invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.", - "unknown": "Unexpected error. Check the Home Assistant logs for details." - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - }, - "options": { - "step": { - "init": { - "title": "LocalThings options", - "menu_options": { - "settings": "Remote control write settings", - "debug_write": "Debug: write to a resource" - } - }, - "settings": { - "title": "Remote control write settings", - "description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.", - "data": { - "bypass_remote_control_lock": "Allow writes even when remote control is reported off" - } - }, - "debug_write": { - "title": "Debug: write to a resource", - "description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.", - "data": { - "href": "Resource href" - } - }, - "debug_edit": { - "title": "Debug write: {href}", - "description": "Current value of `{href}`:\n```\n{current_value}\n```\nEnter ONLY the field(s) you want to change below. What you enter is sent to the device as-is (a partial update); it is NOT merged with the fields shown above, so don't paste the whole value back. Mind the types: a numeric string like \"1\" must stay quoted -- a bare 1 is sent as an integer, which some devices reject.", - "data": { - "payload": "Payload to write" - } - }, - "debug_result": { - "title": "Debug write result", - "description": "The device returned CoAP code {code}. A 2.xx class means the write was accepted; 4.xx/5.xx means it was rejected. The resource now reads:\n```\n{new_value}\n```\nIf the value is unchanged, the device likely dropped the write. Pick what to do next:", - "menu_options": { - "debug_write": "Write another resource", - "finish": "Finish" - } - } - }, - "error": { - "empty_payload": "Enter at least one field to write.", - "write_failed": "The write failed. Check the Home Assistant logs for details." - }, - "abort": { - "not_loaded": "This device isn't connected yet. Try again once it has loaded." - } - }, - "issues": { - "device_gap": { - "title": "Incomplete capability coverage for {device_name}", - "description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template." - } - }, - "exceptions": { - "remote_control_disabled": { - "message": "Remote control is turned off on this device. Check your appliance's manual for how to enable remote control before Home Assistant can control it." - }, - "debug_payload_empty": { - "message": "Debug write payload must be a non-empty object." - }, - "resource_href_required": { - "message": "A resource href is required." - }, - "bubble_soak_unavailable_for_cycle": { - "message": "Bubble Soak isn't available on the selected cycle." - }, - "pre_wash_unavailable_for_cycle": { - "message": "Pre-wash isn't available on the selected cycle." - }, - "intensive_unavailable_for_cycle": { - "message": "Intensive isn't available on the selected cycle." - } - } -} diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b6a8dd2..5d3936c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -616,13 +616,13 @@ "name": "Auto door opener" }, "auto_release_dry": { - "name": "Auto Release Dry" + "name": "Auto release dry" }, "autofill": { "name": "Autofill pitcher" }, "bubble_soak": { - "name": "Bubble Soak" + "name": "Bubble soak" }, "cabinet_light_dim": { "name": "Brighten gradually" diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 473d64d..c199f43 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -228,7 +228,7 @@ class TestAiEnergyLevelSelect: def test_no_translation_key(self): """aiLevel's values are plain digits that render fine untranslated - (select.py's _display()) -- no strings.json entry to maintain + (select.py's _display()) -- no catalog entry to maintain against an unknown number of future levels.""" desc = self._desc() assert desc.translation_key is None diff --git a/tests/test_select_display.py b/tests/test_select_display.py index a3e5ee3..ab9d8c8 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -28,7 +28,7 @@ def test_display_passes_through_an_already_human_friendly_value(): def test_display_lowercases_for_translation_key_lookup(): - """An entity with a translation_key must match strings.json's + """An entity with a translation_key must match the catalog's lowercase keys exactly -- unlike the untranslated cases above, this is not a cosmetic transform.""" assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3' diff --git a/tests/test_translations.py b/tests/test_translations.py index c5bb553..a8cb554 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -1,4 +1,13 @@ -"""Translation architecture and catalog synchronization tests.""" +"""Translation catalog architecture tests. + +Home Assistant loads a custom integration's ``translations/.json`` +directly -- there is no ``strings.json`` step and no ``[%key:...%]`` +resolution, both of which belong to Core's build tooling. So +``translations/en.json`` is the source of truth here, and these tests hold +the two invariants that follow from that: every translation key the Python +side names has to exist in it, and every other language has to mirror its +shape. +""" from __future__ import annotations @@ -9,16 +18,22 @@ from string import Formatter from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.entities import PLATFORM_OF -from custom_components.localthings.select import TRANSLATED_SELECT_STATES INTEGRATION = ( Path(__file__).parents[1] / "custom_components" / "localthings" ) +TRANSLATIONS = INTEGRATION / "translations" -def _load(name: str) -> dict: - return json.loads((INTEGRATION / name).read_text(encoding="utf-8")) +def _load(language: str) -> dict: + return json.loads( + (TRANSLATIONS / f"{language}.json").read_text(encoding="utf-8") + ) + + +def _languages() -> list[str]: + return sorted(path.stem for path in TRANSLATIONS.glob("*.json")) def _topology(value): @@ -72,46 +87,53 @@ def _all_descriptions(): yield from visit(value) -def test_source_and_english_translation_topology_and_placeholders_match(): - source = _load("strings.json") - english = _load("translations/en.json") - assert _topology(source) == _topology(english) +def test_every_language_mirrors_the_english_catalog(): + """English is the complete catalog; the rest must match it key for key. + A missing key silently falls back to English at runtime, so checking the + shape is the only way to notice a half-finished translation. + """ + english = _load("en") english_strings = dict(_walk_strings(english)) - for path, value in _walk_strings(source): - assert _placeholders(value) == _placeholders(english_strings[path]), path + for language in _languages(): + if language == "en": + continue + translated = _load(language) + assert _topology(english) == _topology(translated), language + + translated_strings = dict(_walk_strings(translated)) + for path, value in english_strings.items(): + # Placeholders are substituted by name, so a translation that + # drops or invents one renders a literal '{...}' in the UI. + assert _placeholders(value) == _placeholders( + translated_strings[path] + ), (language, path) -def test_runtime_translation_catalogs_have_no_unresolved_references(): - for language in ("en", "nl"): +def test_no_catalog_carries_unresolved_core_references(): + """``[%key:...%]`` never resolves for a custom integration. + + Core's build tooling expands these; nothing does for us, so a reference + left in a catalog would reach the UI verbatim. + """ + for language in _languages(): unresolved = [ (path, value) - for path, value in _walk_strings( - _load(f"translations/{language}.json") - ) + for path, value in _walk_strings(_load(language)) if "[%key:" in value ] - assert unresolved == [] - - -def test_english_and_dutch_catalog_topology_and_placeholders_match(): - english = _load("translations/en.json") - dutch = _load("translations/nl.json") - assert _topology(english) == _topology(dutch) - - dutch_strings = dict(_walk_strings(dutch)) - for path, value in _walk_strings(english): - assert _placeholders(value) == _placeholders(dutch_strings[path]), path + assert unresolved == [], language def test_every_translatable_descriptor_has_an_entity_catalog_entry(): - entity_strings = _load("strings.json")["entity"] + entity_strings = _load("en")["entity"] missing = [] for desc in _all_descriptions(): translation_key = desc.translation_key if callable(translation_key): - # Runtime table resolvers use the generic name-only fallback plus - # the static tables currently documented by this integration. + # 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 @@ -122,23 +144,36 @@ def test_every_translatable_descriptor_has_an_entity_catalog_entry(): missing.append((platform, desc.key, translation_key)) assert missing == [] + # cycle_select falls back to 'cycle' for any course table without its + # own entry, and resolves to '_cycle_
' where there is one. select_strings = entity_strings["select"] for key in ("cycle", "washer_cycle_table_02", "dryer_cycle_table_03"): assert key in select_strings -def test_select_state_normalization_is_synchronized_with_catalog(): - select_strings = _load("strings.json")["entity"]["select"] - translated = { - key: frozenset(value["state"]) - for key, value in select_strings.items() - if "state" in value - } - assert TRANSLATED_SELECT_STATES == translated +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("strings.json")["entity"] + entity_strings = _load("en")["entity"] for platform in entity_strings.values(): for translation in platform.values(): for state_key in translation.get("state", {}): diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index b8655ce..a7b876e 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -76,7 +76,7 @@ class TestWasherCourse: def test_reads_raw_course_code_from_options_array(self): """rep_fn returns the raw device code; display names come from - strings.json via translation_key, not from Python (see select.py's + translations/en.json via translation_key, not Python (see select.py's _display()).""" desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} From 10c850f2b9162690fc7f2aad7cd63d271bcb46b5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 19:45:37 +0000 Subject: [PATCH 8/8] 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 --- .claude/skills/adding-device-support/SKILL.md | 51 +++++++++---- custom_components/localthings/entity.py | 42 +++++------ .../registry/capabilities/air_purifier.py | 30 ++++---- .../registry/capabilities/airconditioner.py | 16 ++--- .../registry/capabilities/common.py | 45 ++++++------ .../registry/capabilities/cooktop.py | 12 ---- .../registry/capabilities/dishwasher.py | 16 ++--- .../registry/capabilities/dryer.py | 10 +-- .../registry/capabilities/fridge.py | 71 ++++++++++--------- .../registry/capabilities/laundry.py | 27 ++++--- .../registry/capabilities/operational.py | 18 +++-- .../localthings/registry/capabilities/oven.py | 32 ++++----- .../registry/capabilities/range.py | 9 ++- .../registry/capabilities/range_hood.py | 18 ----- .../registry/capabilities/washer.py | 44 ++++++------ .../localthings/registry/discovery.py | 2 +- .../localthings/registry/entities.py | 11 +-- tests/test_entity_naming.py | 10 ++- tests/test_select_options.py | 2 +- tests/test_translations.py | 42 +++++------ 20 files changed, 241 insertions(+), 267 deletions(-) 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():