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]}