From 9acc6bc8ce3d0af85f37ae765d10335d275ceffa Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 27 Jun 2026 20:45:28 -0500 Subject: [PATCH] feat: add HA-shaped entity description dataclasses --- samsung_appliance/registry/__init__.py | 0 samsung_appliance/registry/entities.py | 79 ++++++++++++++++++++++++++ tests/test_entities.py | 34 +++++++++++ 3 files changed, 113 insertions(+) create mode 100644 samsung_appliance/registry/__init__.py create mode 100644 samsung_appliance/registry/entities.py create mode 100644 tests/test_entities.py diff --git a/samsung_appliance/registry/__init__.py b/samsung_appliance/registry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samsung_appliance/registry/entities.py b/samsung_appliance/registry/entities.py new file mode 100644 index 0000000..10da78b --- /dev/null +++ b/samsung_appliance/registry/entities.py @@ -0,0 +1,79 @@ +"""HA-shaped entity descriptions. The subclass *type* selects the HA platform. + +Frozen dataclasses so the future native HA component can consume them as +EntityDescription subclasses unchanged. Read transforms live in value_fn; +presence gating in exists_fn; write logic in write_fn on command platforms. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] + + +def _identity(v: Any) -> Any: + return v + + +@dataclass(frozen=True, kw_only=True) +class SamsungEntityDescription: + key: str + field: str = '' + name: Optional[str] = None + translation_key: Optional[str] = None + icon: Optional[str] = None + entity_category: Optional[str] = None # 'diagnostic' | 'config' | None + enabled_default: bool = True + value_fn: Callable[[Any], Any] = _identity + exists_fn: Optional[Callable[[dict], bool]] = None + + +@dataclass(frozen=True, kw_only=True) +class SensorDesc(SamsungEntityDescription): + device_class: Optional[str] = None + state_class: Optional[str] = None + unit: Optional[str] = None + + +@dataclass(frozen=True, kw_only=True) +class BinarySensorDesc(SamsungEntityDescription): + device_class: Optional[str] = None # value_fn must return bool + + +@dataclass(frozen=True, kw_only=True) +class SelectDesc(SamsungEntityDescription): + options: Any = () # tuple[str,...] | Callable[[dict], list[str]] + write_fn: WriteFn = None + + +@dataclass(frozen=True, kw_only=True) +class SwitchDesc(SamsungEntityDescription): + device_class: Optional[str] = None + write_fn: WriteFn = None + + +@dataclass(frozen=True, kw_only=True) +class ButtonDesc(SamsungEntityDescription): + payload: str = '' + write_fn: WriteFn = None + + +@dataclass(frozen=True, kw_only=True) +class NumberDesc(SamsungEntityDescription): + device_class: Optional[str] = None + unit: Optional[str] = None + native_min: Optional[float] = None + native_max: Optional[float] = None + step: Optional[float] = None + write_fn: WriteFn = None + + +PLATFORM_OF: dict[type, str] = { + SensorDesc: 'sensor', + BinarySensorDesc: 'binary_sensor', + SelectDesc: 'select', + SwitchDesc: 'switch', + ButtonDesc: 'button', + NumberDesc: 'number', +} diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..0a937a1 --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,34 @@ +from samsung_appliance.registry.entities import ( + SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc, + NumberDesc, PLATFORM_OF, +) + + +def test_value_fn_defaults_to_identity(): + d = SensorDesc(key='power', field='x.com.samsung.da.instantaneousPower') + assert d.value_fn(42) == 42 + + +def test_descriptions_are_frozen(): + d = SensorDesc(key='power', field='f') + try: + d.key = 'other' + except Exception as e: + assert 'frozen' in str(type(e)).lower() or 'cannot' in str(e).lower() + else: + raise AssertionError("expected frozen dataclass") + + +def test_platform_mapping_covers_all_subclasses(): + assert PLATFORM_OF[SensorDesc] == 'sensor' + assert PLATFORM_OF[BinarySensorDesc] == 'binary_sensor' + assert PLATFORM_OF[SelectDesc] == 'select' + assert PLATFORM_OF[SwitchDesc] == 'switch' + assert PLATFORM_OF[ButtonDesc] == 'button' + assert PLATFORM_OF[NumberDesc] == 'number' + + +def test_select_carries_options_and_write_fn(): + d = SelectDesc(key='sound', field='mode', options=('voice', 'tone', 'mute'), + write_fn=lambda p, rep: (['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})) + assert d.write_fn('tone', {}) == (['settings', 'sound', 'mode', 'vs', '0'], {'mode': 'tone'})