Files
localthings/custom_components/localthings/registry/entities.py
T
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.

Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).

Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
2026-08-02 23:56:38 +00:00

160 lines
6.6 KiB
Python

"""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;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
# (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).
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
def _identity(v: Any) -> Any:
return v
@dataclass(frozen=True, kw_only=True)
class SamsungEntityDescription:
key: str
field: str = ""
# 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 -- 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: Mapping[str, str] | None = 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: str | None = None
entity_category: str | None = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
# (rep, resources): rep is this entity's own href's representation;
# resources is the coordinator's full href->rep snapshot, for gating
# presence on a sibling resource (e.g. laundry.cycle_options's source).
exists_fn: Callable[[dict, dict], bool] | None = None
@dataclass(frozen=True, kw_only=True)
class SensorDesc(SamsungEntityDescription):
device_class: str | None = None
state_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
options: tuple | None = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
hysteresis: bool = False
@dataclass(frozen=True, kw_only=True)
class BinarySensorDesc(SamsungEntityDescription):
device_class: str | None = None # value_fn must return bool
@dataclass(frozen=True, kw_only=True)
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot (not just this entity's own href) and returns raw device
# option values; see select.py's LocalThingsSelect._raw_options().
options_field: str | None = None # resource field that contains the live options list
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class SwitchDesc(SamsungEntityDescription):
device_class: str | None = None
write_fn: WriteFn = None
validate_fn: ValidateFn = 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: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
native_min: float | None = None
native_max: float | None = None
step: float | None = None
# Override native_min/native_max/step from the live rep, when set --
# same "static default, live override" shape as unit_fn, for resources
# whose sane bounds depend on a per-device value (e.g. a temperature
# setpoint reported in Celsius on one device, Fahrenheit on another).
native_min_fn: Callable[[dict], float] | None = None
native_max_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None
range_field: str | None = None # resource field containing [min, max] list
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class TimeDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class ClimateDesc(SamsungEntityDescription):
# A composite entity: it binds one *primary* resource (its href) but the
# climate platform reads sibling resources (power, temperature, wind) from
# the coordinator snapshot and writes to several of them. write_fn takes a
# (kind, value) payload from the platform and returns the (path_segs, body)
# for that one sub-write, so a single desc drives multi-resource writes.
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
@dataclass(frozen=True, kw_only=True)
class WaterHeaterDesc(SamsungEntityDescription):
# Composite water_heater entity: binds one primary resource (its href,
# typically an operation-mode resource) but the water_heater platform
# reads sibling resources (power, temperature) from the coordinator
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
# body) write_fn shape as ClimateDesc/FanDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: "sensor",
BinarySensorDesc: "binary_sensor",
SelectDesc: "select",
SwitchDesc: "switch",
ButtonDesc: "button",
NumberDesc: "number",
TimeDesc: "time",
ClimateDesc: "climate",
FanDesc: "fan",
WaterHeaterDesc: "water_heater",
}