diff --git a/README.md b/README.md index 7d76a44..33ef700 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi |---|---| | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | +| Cooktop (read-only burner/hood status) | `by_type/cooktop.py` | +| Range hood | `by_type/range_hood.py` | | Dishwasher | `by_type/dishwasher.py` | | Refrigerator | `by_type/refrigerator.py` | | Washer | `by_type/washer.py` | @@ -99,7 +101,7 @@ custom_components/localthings/ diagnostics.py Redacted diagnostics download (device state + coverage metadata) const.py Domain, config keys, probe ports entity.py Base entity wiring capability registry -> HA entity - sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py + sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py One module per HA platform strings.json / translations/ Config-flow copy + entity state translations registry/ @@ -110,8 +112,9 @@ custom_components/localthings/ adapter.py Flattens bound entities into HA-ready state identity.py Reads device identity for type detection redact.py Strips account/identity data before diagnostics leave HA - capabilities/ Shared + per-family Capability definitions (common, dryer, oven, - dishwasher, fridge, washer, laundry, operational, ignored) + capabilities/ Shared + per-family Capability definitions (common, cooktop, + range_hood, dryer, oven, dishwasher, fridge, washer, laundry, + operational, ignored) by_type/ One DeviceRegistry per appliance type, composed from capabilities/ tests/ Registry composition, discovery, entity descriptors, coordinator/observe behavior, and golden-file regression against captured device dumps @@ -137,7 +140,7 @@ support for hardware the maintainers don't have. 1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources. 2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type. 3. Create `registry/by_type/.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern. -4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion` — confirmed true for washers — add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` instead, so `for_device_by_model()` can route it. +4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`. 5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type. No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry. diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 3b55c17..14644d1 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -195,7 +195,9 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: import cbor2 from smartthings_local.protocol.dtls_session import DtlsCoapSession from .registry.batch import parse_device0_batch - from .registry.by_type import for_device, for_device_by_model + from .registry.by_type import ( + for_device, for_device_by_model, for_device_by_resources, + ) _LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST) try: @@ -266,7 +268,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: ) or for_device_by_model( info_resource.get('x.com.samsung.da.modelNum', ''), info_resource.get('x.com.samsung.da.description', ''), - ) + ) or for_device_by_resources(resources) return { "port": port, "serial": serial, diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 8502bc9..bad8b37 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -1,6 +1,9 @@ DOMAIN = "localthings" -PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time", "climate"] +PLATFORMS = [ + "sensor", "binary_sensor", "switch", "number", "select", "button", + "time", "climate", "fan", +] CONF_HOST = "host" CONF_PORT = "port" diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index a7a9c6a..8b5f44f 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -20,7 +20,7 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession from smartthings_local.ocf.state_cache import StateCache from .registry.batch import parse_device0_batch -from .registry.by_type import for_device, for_device_by_model +from .registry.by_type import for_device, for_device_by_model, for_device_by_resources from .registry.discovery import discover, BoundEntity from .registry import CAPABILITIES from .registry.adapter import flatten @@ -287,6 +287,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): info.get('x.com.samsung.da.modelNum', ''), info.get('x.com.samsung.da.description', ''), ) + if reg is None: + reg = for_device_by_resources(resources) unbound: list[str] = [] if reg is not None: self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui) @@ -306,7 +308,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.device_serial = serial ident = self._identity - device_type = reg.name.title() if reg else 'Appliance' + 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}" diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py new file mode 100644 index 0000000..3727d9c --- /dev/null +++ b/custom_components/localthings/fan.py @@ -0,0 +1,110 @@ +"""Fan platform for Samsung range hoods.""" + +from __future__ import annotations + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DOMAIN +from .coordinator import LocalThingsCoordinator +from .entity import LocalThingsEntity, _is_included +from .registry.entities import FanDesc + + +POWER_HREF = '/power/0' +POWER_VS_HREF = '/power/vs/0' +_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed' +_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed' + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + LocalThingsRangeHoodFan(coordinator, bound) + for bound in coordinator.bound + if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator) + ) + + +class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): + """A hood fan combining sibling power and fan-speed resources.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) + + def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: + super().__init__(coordinator, bound) + self._attr_name = None + + def _rep(self, href: str) -> dict: + return self.coordinator.resource(href) or {} + + def _all_speed_codes(self) -> list[str]: + rep = self._rep(self._bound.href) + return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())] + + def _active_speed_codes(self) -> list[str]: + # Power is carried by the separate /power resource. fanSpeed retains + # the selected setting while power is off (as the lamp's `current` + # field does), so every advertised code is an active ordered speed. + return self._all_speed_codes() + + @property + def is_on(self) -> bool: + rep = self._rep(POWER_HREF) + if 'value' in rep: + return bool(rep.get('value')) + return str( + self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '') + ).lower() == 'on' + + @property + def speed_count(self) -> int: + return len(self._active_speed_codes()) + + @property + def percentage(self) -> int | None: + if not self.is_on: + return 0 + codes = self._active_speed_codes() + current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '')) + if not codes or current not in codes: + return None + return ordered_list_item_to_percentage(codes, current) + + async def async_turn_on( + self, percentage: int | None = None, preset_mode: str | None = None, + **kwargs, + ) -> None: + await self.coordinator.async_send_command(self._bound, ('power', True)) + if percentage is not None: + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + await self.coordinator.async_send_command(self._bound, ('power', False)) + + async def async_set_percentage(self, percentage: int) -> None: + if percentage <= 0: + await self.async_turn_off() + return + codes = self._active_speed_codes() + if not codes: + return + if not self.is_on: + await self.coordinator.async_send_command(self._bound, ('power', True)) + code = percentage_to_ordered_list_item(codes, percentage) + await self.coordinator.async_send_command(self._bound, ('speed', code)) diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 8c9021e..16e7556 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -2,17 +2,26 @@ from typing import Optional from ._base import DeviceRegistry -from . import airconditioner, dishwasher, dryer, oven, refrigerator, washer +from . import ( + airconditioner, cooktop, dishwasher, dryer, oven, range_hood, refrigerator, + washer, +) -__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model'] +__all__ = [ + 'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model', + 'for_device_by_resources', +] _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'airconditioner': airconditioner.REGISTRY, 'air_conditioner': airconditioner.REGISTRY, + 'cooktop': cooktop.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, + 'hood': range_hood.REGISTRY, + 'range_hood': range_hood.REGISTRY, 'refrigerator': refrigerator.REGISTRY, 'washer': washer.REGISTRY, } @@ -94,4 +103,38 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token. if key is None and '_PRAC_' in (model_num or ''): key = 'airconditioner' + model_identity = f'{model_num} {description}'.upper() + if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): + key = 'cooktop' + if key is None and model_identity.startswith('AHD-'): + key = 'range_hood' return _REGISTRY_BY_KEY.get(key) if key else None + + +def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]: + """Detect a device family from a distinctive local-resource signature. + + Some newer cooktops omit both ``oneUiVersion`` and + ``/information/vs/0``. Their mode resource still identifies them: it + contains a DeviceType option and multiple per-burner OperationState + options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is + not misclassified. + """ + mode = resources.get('/mode/vs/0', {}) + options = mode.get('x.com.samsung.da.options') or () + has_device_type = any( + isinstance(option, str) and option.startswith('DeviceType_') + for option in options + ) + operation_states = sum( + 1 for option in options + if isinstance(option, str) and option.startswith('OperationState') + ) + if has_device_type and operation_states >= 2: + return _REGISTRY_BY_KEY['cooktop'] + if ( + '/hood/fanspeed/vs/0' in resources + and '/hood/lamp/vs/0' in resources + ): + return _REGISTRY_BY_KEY['range_hood'] + return None diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py new file mode 100644 index 0000000..ffe6400 --- /dev/null +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -0,0 +1,17 @@ +"""Cooktop device registry.""" + +from ..capabilities import cooktop, fridge, ignored +from ._base import DeviceRegistry, _build + + +REGISTRY = DeviceRegistry( + name='cooktop', + capabilities=_build([ + *ignored.IGNORED, + cooktop.COOKTOP_POWER, + cooktop.COOKTOP_MODE, + cooktop.COOKTOP_CONNECTED, + cooktop.PAIRED_HOOD_STATUS, + fridge.FIRMWARE_UPDATE, + ]), +) diff --git a/custom_components/localthings/registry/by_type/range_hood.py b/custom_components/localthings/registry/by_type/range_hood.py new file mode 100644 index 0000000..9d6b8ec --- /dev/null +++ b/custom_components/localthings/registry/by_type/range_hood.py @@ -0,0 +1,22 @@ +"""Range-hood device registry.""" + +from ..capabilities import common, fridge, ignored, range_hood +from ._base import DeviceRegistry, _build + + +REGISTRY = DeviceRegistry( + name='range_hood', + capabilities=_build([ + *ignored.IGNORED, + range_hood.HOOD_ALARMS, + common.ENERGY_METER, + fridge.FIRMWARE_UPDATE, + range_hood.HOOD_FAN, + range_hood.HOOD_LAMP, + range_hood.HOOD_FILTER, + range_hood.AIR_QUALITY, + range_hood.AIR_LEVEL_CHECK, + range_hood.AUTO_VENTILATION, + *range_hood.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/__init__.py b/custom_components/localthings/registry/capabilities/__init__.py index ee654fa..4588c6a 100644 --- a/custom_components/localthings/registry/capabilities/__init__.py +++ b/custom_components/localthings/registry/capabilities/__init__.py @@ -1,4 +1,7 @@ -from . import common, dishwasher, fridge, ignored, laundry, operational, oven +from . import ( + common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven, + range_hood, +) from ..capability import Capability diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py new file mode 100644 index 0000000..4baed74 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -0,0 +1,192 @@ +"""Read-only capabilities for Samsung cooktops. + +The first verified device is an NA9300K-class five-burner gas cooktop. Its +local OCF API reports burner state as strings embedded in the +``x.com.samsung.da.options`` array on ``/mode/vs/0``. Heat-producing controls +are intentionally not exposed: the local write contract is unverified and a +cooktop must not be remotely ignited by an automation. +""" + +import re + +from ..capability import Capability +from ..entities import BinarySensorDesc, SensorDesc + + +_INACTIVE_OPERATION_STATES = {'Off', 'Ready'} + + +def _option_value(options, prefix): + """Return the value from the first ``_`` option.""" + marker = prefix + '_' + for option in options or (): + if isinstance(option, str) and option.startswith(marker): + return option[len(marker):] + return None + + +def _operation_slots(options) -> tuple[int, ...]: + """Return the numeric burner slots advertised in an options array.""" + slots = set() + for option in options or (): + if not isinstance(option, str): + continue + match = re.match(r'^OperationState(\d+)_', option) + if match: + slots.add(int(match.group(1))) + return tuple(sorted(slots)) + + +def _any_burner_active(options): + """True when any advertised burner slot is not idle.""" + states = [ + _option_value(options, f'OperationState{slot}') + for slot in _operation_slots(options) + ] + states = [state for state in states if state is not None] + return any(state not in _INACTIVE_OPERATION_STATES for state in states) + + +def _int_or_none(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +COOKTOP_POWER = Capability( + href='/power/vs/0', + poll_tier='hot', + entities=( + SensorDesc( + key='power_state', + field='x.com.samsung.da.power', + name='Power state', + icon='mdi:stove', + ), + ), +) + + +# NA9300K exposes physical slots 0, 1, 3, 4, and 5. Preserve Samsung's slot +# numbers instead of guessing at front/back/left/right placement. A user can +# safely identify and rename each entity by briefly lighting one burner. +_NA9300K_OPERATION_SLOTS = (0, 1, 3, 4, 5) + +COOKTOP_MODE = Capability( + href='/mode/vs/0', + poll_tier='hot', + entities=( + 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, + ), + *( + SensorDesc( + key=f'burner_{slot}_state', + field='x.com.samsung.da.options', + name=f'Burner {slot} state', + icon='mdi:gas-burner', + value_fn=lambda options, slot=slot: _option_value( + options, f'OperationState{slot}' + ), + exists_fn=lambda rep, resources, slot=slot: ( + not rep or _option_value( + rep.get('x.com.samsung.da.options'), + f'OperationState{slot}', + ) is not None + ), + ) + for slot in _NA9300K_OPERATION_SLOTS + ), + 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( + _option_value(options, 'MainTimerCurrent') + ), + ), + ), +) + + +COOKTOP_CONNECTED = Capability( + href='/connected/vs/0', + poll_tier='warm', + entities=( + 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', + ), + ), +) + + +PAIRED_HOOD_STATUS = Capability( + href='/bluetooth/hood/status/vs/0', + poll_tier='hot', + entities=( + 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, + ), + 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/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py new file mode 100644 index 0000000..698c39a --- /dev/null +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -0,0 +1,295 @@ +"""Capabilities for Samsung AHD-WW-TP1-22 range hoods. + +The verified device exposes its fan, two-level work lamp, washable-filter +status, and particulate sensors as distinct local OCF resources. Fan power +and speed are combined into one HA fan entity by ``fan.py``; lamp power and +brightness remain separate controls because the device advertises them as two +independent fields. +""" + +from datetime import datetime, timezone + +from ..capability import Capability +from ..entities import ( + BinarySensorDesc, + FanDesc, + SelectDesc, + SensorDesc, + SwitchDesc, +) + + +def _int_or_none(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _timestamp(value): + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except (TypeError, ValueError, OSError): + return None + + +def _item_value(items, sensor_type, index=0): + for item in items or (): + if not isinstance(item, dict): + continue + if item.get('x.com.samsung.da.type') != sensor_type: + continue + values = item.get('x.com.samsung.da.value') or () + if index < len(values): + return _int_or_none(values[index]) + return None + + +def _active_alarm_codes(items): + """Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.""" + codes = [] + for item in items or (): + if not isinstance(item, dict): + continue + if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted': + continue + code = item.get('x.com.samsung.da.code') + if code and str(code).lower() != 'errorcode_off': + codes.append(code) + return ', '.join(codes) if codes else 'none' + + +HOOD_ALARMS = Capability( + href='/alarms/vs/0', + poll_tier='hot', + entities=( + SensorDesc( + key='alarm_code', + field='x.com.samsung.da.items', + name='Alarm code', + icon='mdi:alert', + entity_category='diagnostic', + value_fn=_active_alarm_codes, + ), + ), +) + + +def _hood_fan_write(payload, rep, href=None): + kind, value = payload + if kind == 'power': + return ['power', '0'], {'value': bool(value)} + if kind == 'speed': + value = str(value) + supported = [ + str(code) + for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ()) + ] + if value not in supported: + return None + return ['hood', 'fanspeed', 'vs', '0'], { + 'x.com.samsung.da.hood.fanSpeed': value, + } + return None + + +HOOD_FAN = Capability( + href='/hood/fanspeed/vs/0', + poll_tier='hot', + entities=( + 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', + ), + ), +) + + +def _lamp_level_write(value, rep, href=None): + code = str(value) + supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())] + if code not in supported: + return None + return ['hood', 'lamp', 'vs', '0'], { + 'x.com.samsung.lamp.current': code, + } + + +HOOD_LAMP = Capability( + href='/hood/lamp/vs/0', + poll_tier='hot', + entities=( + 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: ( + ['hood', 'lamp', 'vs', '0'], + {'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'}, + ), + ), + 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', + write_fn=_lamp_level_write, + ), + ), +) + + +HOOD_FILTER = Capability( + href='/filter/hoodfilter/vs/0', + poll_tier='cold', + entities=( + SensorDesc( + key='hood_filter_usage', + field='x.com.samsung.da.filterUsage', + name='Filter usage', + unit='%', + state_class='measurement', + icon='mdi:air-filter', + entity_category='diagnostic', + value_fn=_int_or_none, + ), + SensorDesc( + key='hood_filter_status', + field='x.com.samsung.da.filterStatus', + name='Filter status', + icon='mdi:air-filter', + entity_category='diagnostic', + ), + SensorDesc( + key='hood_filter_capacity', + field='x.com.samsung.da.filterCapacity', + name='Filter capacity', + unit='h', + icon='mdi:timer-outline', + entity_category='diagnostic', + enabled_default=False, + value_fn=_int_or_none, + ), + ), +) + + +AIR_QUALITY = Capability( + href='/sensors/vs/0', + poll_tier='warm', + entities=( + SensorDesc( + key='clean_level', + field='x.com.samsung.da.items', + name='Clean level', + icon='mdi:air-filter', + value_fn=lambda items: _item_value(items, 'CleanLevel'), + ), + SensorDesc( + key='dust', + field='x.com.samsung.da.items', + name='Dust', + value_fn=lambda items: _item_value(items, 'Dust'), + ), + SensorDesc( + key='fine_dust', + field='x.com.samsung.da.items', + name='Fine dust', + value_fn=lambda items: _item_value(items, 'FineDust'), + ), + SensorDesc( + key='super_fine_dust', + field='x.com.samsung.da.items', + name='Super fine dust', + value_fn=lambda items: _item_value(items, 'SuperFineDust'), + ), + ), +) + + +AIR_LEVEL_CHECK = Capability( + href='/airlevelcheck/vs/0', + poll_tier='warm', + entities=( + 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', + ), + 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, + ), + 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', + ), + ), +) + + +AUTO_VENTILATION = Capability( + href='/autoventilation/vs/0', + poll_tier='warm', + entities=( + SensorDesc( + key='auto_ventilation_action', + field='action', + name='Auto ventilation action', + icon='mdi:fan-auto', + ), + ), +) + + +# Resource plumbing and opaque feature-negotiation fields that are specific to +# this family. Bare capabilities mark them covered without creating entities. +COVERAGE = [ + Capability(href=href) + for href in ( + '/power/0', + '/power/vs/0', + '/mode/vs/0', + '/personality/presence/vs/0', + '/availablecontrolsets/vs/0', + '/da/softreset/vs/0', + ) +] diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index a22d056..0072441 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -96,6 +96,13 @@ class ClimateDesc(SamsungEntityDescription): write_fn: WriteFn = None +@dataclass(frozen=True, kw_only=True) +class FanDesc(SamsungEntityDescription): + # Composite fan entity: reads power from /power/0 and speed/support data + # from its bound href. Payloads are (kind, value), like ClimateDesc. + write_fn: WriteFn = None + + PLATFORM_OF: dict[type, str] = { SensorDesc: 'sensor', BinarySensorDesc: 'binary_sensor', @@ -105,4 +112,5 @@ PLATFORM_OF: dict[type, str] = { NumberDesc: 'number', TimeDesc: 'time', ClimateDesc: 'climate', + FanDesc: 'fan', } diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 2647ab0..6f23953 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -16,6 +16,12 @@ "100": "High" } }, + "range_hood_lamp_brightness": { + "state": { + "1": "Level 1", + "2": "Level 2" + } + }, "ice_type": { "state": { "off": "Off", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 2647ab0..6f23953 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -16,6 +16,12 @@ "100": "High" } }, + "range_hood_lamp_brightness": { + "state": { + "1": "Level 1", + "2": "Level 2" + } + }, "ice_type": { "state": { "off": "Off", diff --git a/tests/fixtures/cooktop_device.json b/tests/fixtures/cooktop_device.json new file mode 100644 index 0000000..4034c6e --- /dev/null +++ b/tests/fixtures/cooktop_device.json @@ -0,0 +1,73 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.options": [ + "DeviceType_NA9300K-/AA1", + "MainTimerCurrent_0", + "MainTimerSet_0", + "MainTimerState_Ready", + "OperationState0_Ready", + "OperationState1_Ready", + "OperationState3_Ready", + "OperationState4_Ready", + "OperationState5_Ready" + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On" + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/bluetooth/hood/status/vs/0", + "rep": { + "connectionState": "disconnected", + "micomModelId": "", + "firmwareVersion": "", + "power": "off", + "fanSpeed": 0, + "lampState": "off", + "timer": {} + } + } + ] +} diff --git a/tests/fixtures/golden/cooktop.json b/tests/fixtures/golden/cooktop.json new file mode 100644 index 0000000..6e32a16 --- /dev/null +++ b/tests/fixtures/golden/cooktop.json @@ -0,0 +1,21 @@ +{ + "state_keys": [ + "any_burner_active", + "burner_0_state", + "burner_1_state", + "burner_3_state", + "burner_4_state", + "burner_5_state", + "cloud_connected", + "firmware_update", + "main_timer_current", + "main_timer_state", + "paired_hood_connected", + "paired_hood_fan_speed", + "paired_hood_firmware", + "paired_hood_light", + "paired_hood_model", + "paired_hood_power", + "power_state" + ] +} diff --git a/tests/fixtures/golden/range_hood.json b/tests/fixtures/golden/range_hood.json new file mode 100644 index 0000000..411cf7f --- /dev/null +++ b/tests/fixtures/golden/range_hood.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "air_sensing_state", + "alarm_code", + "auto_ventilation_action", + "automatic_operation", + "automatic_ventilation_state", + "clean_level", + "energy_kwh", + "fan", + "firmware_update", + "hood_filter_capacity", + "hood_filter_status", + "hood_filter_usage", + "lamp", + "lamp_brightness", + "last_air_sensing_level", + "last_air_sensing_time", + "periodic_air_sensing", + "dust", + "fine_dust", + "super_fine_dust" + ] +} diff --git a/tests/fixtures/range_hood_device.json b/tests/fixtures/range_hood_device.json new file mode 100644 index 0000000..88607e6 --- /dev/null +++ b/tests/fixtures/range_hood_device.json @@ -0,0 +1,199 @@ +{ + "device0": [ + { + "rt": ["x.com.samsung.devcol", "oic.wk.col"], + "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "TEST-DEVICE-ID", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingActivationState": "Off", + "x.com.samsung.da.periodicSensingInterval": "3600", + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1784651378", + "x.com.samsung.da.lastSensingLevel": "Kr2", + "x.com.samsung.da.autoExeState": "Sensing", + "x.com.samsung.da.supportedAutoExeState": ["Airpurify", "Alarm"], + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-21T20:18:51", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "165620", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeDate": "1784647130", + "x.com.samsung.da.cumulativeDateUTC": "1784665130", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + {"href": "/energy/consumption/0", "rep": {}}, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": ["OptionCode_19322", "UpdateAllow_NotAllowed"] + } + }, + { + "href": "/power/vs/0", + "rep": {"x.com.samsung.da.power": "Off"} + }, + {"href": "/power/0", "rep": {"value": false}}, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": ["18", "1"] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": ["14", "1"] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": ["9", "1"] + } + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000", + "x.com.samsung.da.description": "AHD-WW-TP1-22-COMMON", + "x.com.samsung.da.serialNum": "TEST-HOOD-SERIAL", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagSetupid": "KH0" + } + }, + { + "href": "/file/information/vs/0", + "rep": {"x.com.samsung.timeoffset": "-05:00"} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": ["DR"], + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "durationminutes": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "0000000000012000000000000000", + "x.com.samsung.da.id": "HOOD", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": {"x.com.samsung.da.softwarereset": "false"} + }, + { + "href": "/filter/hoodfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "60", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": ["replaceable", "washable"] + } + }, + { + "href": "/hood/fanspeed/vs/0", + "rep": { + "x.com.samsung.da.hood.autoOperation": "Off", + "x.com.samsung.da.hood.fanSpeed": "14", + "x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"] + } + }, + { + "href": "/hood/lamp/vs/0", + "rep": { + "x.com.samsung.lamp.power": "Off", + "x.com.samsung.lamp.current": "2", + "x.com.samsung.lamp.range": ["1", "2"] + } + }, + {"href": "/autoventilation/vs/0", "rep": {"action": "Start"}}, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + } + ] +} diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 20c7c54..3faa9e1 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -134,6 +134,31 @@ def test_run_discovery_detects_washer_via_model_fallback( assert coordinator.device_type_name == 'washer' +def test_run_discovery_detects_cooktop_via_resource_signature( + hass: HomeAssistant, mock_entry +) -> None: + """NA9300K omits oneUiVersion and information but has burner options.""" + from tests.conftest import _load_device + + coordinator = LocalThingsCoordinator(hass, mock_entry) + coordinator._run_discovery(_load_device('cooktop')) + + assert coordinator.device_type_name == 'cooktop' + assert coordinator._unbound_hrefs == [] + + +def test_run_discovery_detects_range_hood_via_model( + hass: HomeAssistant, mock_entry +) -> None: + from tests.conftest import _load_device + + coordinator = LocalThingsCoordinator(hass, mock_entry) + coordinator._run_discovery(_load_device('range_hood')) + + assert coordinator.device_type_name == 'range_hood' + assert coordinator._unbound_hrefs == [] + + def test_update_coverage_gap_issue_creates_issue_for_unknown_type( hass: HomeAssistant, mock_entry ) -> None: diff --git a/tests/test_by_type.py b/tests/test_by_type.py index ff2caaa..6c62890 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -41,6 +41,16 @@ class TestForDevice: assert isinstance(registry, DeviceRegistry) assert registry.name == 'refrigerator' + def test_for_device_returns_cooktop_registry(self): + registry = for_device('7.0 Cooktop') + assert registry is not None + assert registry.name == 'cooktop' + + def test_for_device_returns_range_hood_registry(self): + registry = for_device('7.0 Range Hood') + assert registry is not None + assert registry.name == 'range_hood' + class TestDeviceRegistries: """Tests for device registries themselves.""" @@ -180,6 +190,25 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_cooktop_via_legacy_model_description(self): + """Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000', + 'ARTIK051_GLOBAL_COOKTOP', + ) + assert reg is not None + assert reg.name == 'cooktop' + + def test_range_hood_via_ahd_model(self): + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000', + 'AHD-WW-TP1-22-COMMON', + ) + assert reg is not None + assert reg.name == 'range_hood' + def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') @@ -188,3 +217,38 @@ class TestForDeviceByModel: def test_empty_inputs_return_none(self): from custom_components.localthings.registry.by_type import for_device_by_model assert for_device_by_model('', '') is None + + +class TestForDeviceByResources: + def test_na9300k_without_one_ui_or_information_is_cooktop(self): + from custom_components.localthings.registry.by_type import for_device_by_resources + from tests.conftest import _load_device + + reg = for_device_by_resources(_load_device('cooktop')) + + assert reg is not None + assert reg.name == 'cooktop' + + def test_unrelated_mode_options_are_not_cooktop(self): + from custom_components.localthings.registry.by_type import for_device_by_resources + + resources = { + '/mode/vs/0': { + 'x.com.samsung.da.options': [ + 'DeviceType_SOME_OVEN', + 'UpperLamp_Off', + ], + }, + } + + assert for_device_by_resources(resources) is None + + def test_hood_resource_signature(self): + from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { + '/hood/fanspeed/vs/0': {}, + '/hood/lamp/vs/0': {}, + } + reg = for_device_by_resources(resources) + assert reg is not None + assert reg.name == 'range_hood' diff --git a/tests/test_cooktop_capabilities.py b/tests/test_cooktop_capabilities.py new file mode 100644 index 0000000..c240141 --- /dev/null +++ b/tests/test_cooktop_capabilities.py @@ -0,0 +1,68 @@ +"""Tests for the read-only cooktop capability profile.""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import cooktop +from custom_components.localthings.registry.capabilities.cooktop import ( + COOKTOP_MODE, +) +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + + +def _state(resources): + bound = discover( + resources, + cooktop.REGISTRY.capabilities, + cooktop.REGISTRY.pattern_capabilities, + ) + return flatten(bound, resources) + + +def test_real_cooktop_fixture_has_expected_idle_state(): + state = _state(_load_device('cooktop')) + + assert state['power_state'] == 'On' + assert state['any_burner_active'] is False + assert state['burner_0_state'] == 'Ready' + assert state['burner_5_state'] == 'Ready' + assert state['main_timer_state'] == 'Ready' + assert state['main_timer_current'] == 0 + assert state['cloud_connected'] is True + assert state['paired_hood_connected'] is False + assert state['paired_hood_power'] is False + assert state['paired_hood_fan_speed'] == 0 + assert state['paired_hood_light'] is False + + +def test_run_operation_marks_any_burner_active(): + resources = _load_device('cooktop') + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + options[options.index('OperationState3_Ready')] = 'OperationState3_Run' + + state = _state(resources) + + assert state['burner_3_state'] == 'Run' + assert state['any_burner_active'] is True + + +def test_cooktop_profile_has_no_write_functions(): + """A local integration must never expose unverified remote ignition.""" + descriptions = [ + desc + for capabilities in cooktop.REGISTRY.capabilities.values() + for capability in capabilities + for desc in capability.entities + ] + + assert descriptions + assert all(not hasattr(desc, 'write_fn') or desc.write_fn is None + for desc in descriptions) + + +def test_known_na9300k_slots_are_present(): + keys = {desc.key for desc in COOKTOP_MODE.entities} + + assert { + 'burner_0_state', 'burner_1_state', 'burner_3_state', + 'burner_4_state', 'burner_5_state', + } <= keys diff --git a/tests/test_entities.py b/tests/test_entities.py index fce7295..8f24bf4 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,6 +1,6 @@ from custom_components.localthings.registry.entities import ( SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc, - NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF, + NumberDesc, TimeDesc, ClimateDesc, FanDesc, PLATFORM_OF, ) @@ -28,6 +28,7 @@ def test_platform_mapping_covers_all_subclasses(): assert PLATFORM_OF[NumberDesc] == 'number' assert PLATFORM_OF[TimeDesc] == 'time' assert PLATFORM_OF[ClimateDesc] == 'climate' + assert PLATFORM_OF[FanDesc] == 'fan' def test_select_carries_options_and_write_fn(): diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index e9a6cfb..93e0623 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -7,7 +7,9 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden' def _new_state_keys(name, resources): - from custom_components.localthings.registry.by_type import for_device, for_device_by_model + from custom_components.localthings.registry.by_type import ( + for_device, for_device_by_model, for_device_by_resources, + ) from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.adapter import flatten otn = resources.get('/otninformation/vs/0', {}) @@ -19,6 +21,8 @@ def _new_state_keys(name, resources): info.get('x.com.samsung.da.modelNum', ''), info.get('x.com.samsung.da.description', ''), ) + if reg is None: + reg = for_device_by_resources(resources) if reg is None: from custom_components.localthings.registry.registry import CAPABILITIES caps, pats = CAPABILITIES, [] @@ -81,6 +85,30 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_cooktop(): + from tests.conftest import _load_device + resources = _load_device('cooktop') + golden = json.loads((GOLDEN / 'cooktop.json').read_text()) + state_keys = _new_state_keys('cooktop', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + +def test_registry_reproduces_golden_state_keys_for_range_hood(): + from tests.conftest import _load_device + resources = _load_device('range_hood') + golden = json.loads((GOLDEN / 'range_hood.json').read_text()) + state_keys = _new_state_keys('range_hood', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { diff --git a/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py new file mode 100644 index 0000000..aceb68e --- /dev/null +++ b/tests/test_range_hood_capabilities.py @@ -0,0 +1,109 @@ +"""Tests for the AHD-WW-TP1-22 range-hood profile.""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities import range_hood +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import FanDesc +from tests.conftest import _load_device + + +def _hood(): + resources = _load_device('range_hood') + info = resources['/information/vs/0'] + registry = for_device_by_model( + info['x.com.samsung.da.modelNum'], + info['x.com.samsung.da.description'], + ) + return registry, resources + + +def _state(): + registry, resources = _hood() + bound = discover( + resources, registry.capabilities, registry.pattern_capabilities, + ) + return flatten(bound, resources) + + +def test_ahd_model_resolves_to_range_hood_registry(): + registry, _ = _hood() + assert registry is not None + assert registry.name == 'range_hood' + + +def test_range_hood_has_no_unbound_hrefs(): + registry, resources = _hood() + unbound = [] + discover( + resources, + registry.capabilities, + registry.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + + +def test_range_hood_fixture_values(): + state = _state() + assert state['fan'] == '14' + assert state['alarm_code'] == 'none' + assert state['automatic_operation'] is False + assert state['lamp'] is False + assert state['lamp_brightness'] == '2' + assert state['hood_filter_usage'] == 100 + assert state['hood_filter_status'] == 'wash' + assert state['energy_kwh'] == 165.62 + assert state['clean_level'] == 2 + assert state['dust'] == 18 + assert state['fine_dust'] == 14 + assert state['super_fine_dust'] == 9 + assert state['periodic_air_sensing'] is False + assert state['air_sensing_state'] == 'NonProcessing' + assert state['last_air_sensing_level'] == 'Kr2' + + +def test_one_composite_fan_is_bound(): + registry, resources = _hood() + bound = discover( + resources, registry.capabilities, registry.pattern_capabilities, + ) + fans = [entity for entity in bound if isinstance(entity.desc, FanDesc)] + assert len(fans) == 1 + assert fans[0].href == '/hood/fanspeed/vs/0' + + +def test_fan_write_contract(): + desc = range_hood.HOOD_FAN.entities[0] + rep = { + 'x.com.samsung.da.hood.supportedFanSpeed': ['14', '15', '16', '17'], + } + assert desc.write_fn(('power', True), rep) == ( + ['power', '0'], {'value': True}, + ) + assert desc.write_fn(('power', False), rep) == ( + ['power', '0'], {'value': False}, + ) + assert desc.write_fn(('speed', '16'), rep) == ( + ['hood', 'fanspeed', 'vs', '0'], + {'x.com.samsung.da.hood.fanSpeed': '16'}, + ) + assert desc.write_fn(('speed', '99'), rep) is None + + +def test_lamp_write_contract(): + lamp, brightness = range_hood.HOOD_LAMP.entities + rep = {'x.com.samsung.lamp.range': ['1', '2']} + assert lamp.write_fn('On', rep) == ( + ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'On'}, + ) + assert lamp.write_fn('Off', rep) == ( + ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'Off'}, + ) + assert brightness.write_fn('1', rep) == ( + ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '1'}, + ) + assert brightness.write_fn('2', rep) == ( + ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'}, + ) + assert brightness.write_fn('Unsupported', rep) is None diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py new file mode 100644 index 0000000..c26308c --- /dev/null +++ b/tests/test_range_hood_fan.py @@ -0,0 +1,61 @@ +"""HA fan-entity mapping tests for the range hood.""" + +from custom_components.localthings.fan import LocalThingsRangeHoodFan +from custom_components.localthings.registry.by_type import range_hood +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import FanDesc +from tests.conftest import _load_device + + +class _FakeCoordinator: + device_serial = 'TEST-HOOD-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + + def resource(self, href): + return self.last_resources.get(href, {}) + + +def _entity(resources): + bound = discover( + resources, + range_hood.REGISTRY.capabilities, + range_hood.REGISTRY.pattern_capabilities, + ) + fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc)) + return LocalThingsRangeHoodFan(_FakeCoordinator(resources), fan_bound) + + +def test_power_off_maps_to_zero_percent_and_four_retained_speeds(): + entity = _entity(_load_device('range_hood')) + assert entity.is_on is False + assert entity.speed_count == 4 + assert entity.percentage == 0 + + +def test_active_codes_map_to_ordered_percentages(): + resources = _load_device('range_hood') + resources['/power/0']['value'] = True + + resources['/hood/fanspeed/vs/0'][ + 'x.com.samsung.da.hood.fanSpeed' + ] = '14' + assert _entity(resources).percentage == 25 + + resources['/hood/fanspeed/vs/0'][ + 'x.com.samsung.da.hood.fanSpeed' + ] = '15' + assert _entity(resources).percentage == 50 + + resources['/hood/fanspeed/vs/0'][ + 'x.com.samsung.da.hood.fanSpeed' + ] = '16' + assert _entity(resources).percentage == 75 + + resources['/hood/fanspeed/vs/0'][ + 'x.com.samsung.da.hood.fanSpeed' + ] = '17' + assert _entity(resources).percentage == 100