from __future__ import annotations import contextlib from dataclasses import asdict, dataclass, field, fields import enum from functools import cache, lru_cache, partial from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from uuid import UUID from .util import fix_float_single_double_conversion _dataclass_decorator = partial(dataclass, slots=True) _frozen_dataclass_decorator = partial(dataclass, frozen=True, slots=True) if TYPE_CHECKING: from collections.abc import Callable, Iterable from google.protobuf import message from .api_pb2 import ( # type: ignore[attr-defined] BluetoothLEAdvertisementResponse, HomeassistantServiceMap, ) # All fields in here should have defaults set # Home Assistant depends on these fields being constructible # with args from a previous version of Home Assistant. # The default value should *always* be the Protobuf default value # for a field (False, 0, empty string, enum with value 0, ...) _V = TypeVar("_V") class APIIntEnum(enum.IntEnum): """Base class for int enum values in API model.""" @classmethod def convert(cls, value: int) -> Self | None: try: return cls(value) except ValueError: return None @classmethod def convert_list(cls, value: list[int]) -> list[Self]: ret = [] for x in value: with contextlib.suppress(ValueError): ret.append(cls(x)) return ret # Fields do not change so we can cache the result # of calling fields() on the dataclass cached_fields = cache(fields) @_frozen_dataclass_decorator class APIModelBase: def __post_init__(self) -> None: for field_ in cached_fields(type(self)): # type: ignore[arg-type] convert = field_.metadata.get("converter") if convert is None: continue val = getattr(self, field_.name) # use this setattr to prevent FrozenInstanceError object.__setattr__(self, field_.name, convert(val)) def to_dict(self) -> dict[str, Any]: return asdict(self) # type: ignore[no-any-return, call-overload] @classmethod def from_dict(cls, data: dict[str, Any], *, ignore_missing: bool = True) -> Self: return cls( **{ f.name: data[f.name] for f in cached_fields(cls) # type: ignore[arg-type] if f.name in data or (not ignore_missing) } ) @classmethod def from_pb(cls, data: Any) -> Self: return cls(**{f.name: getattr(data, f.name) for f in cached_fields(cls)}) # type: ignore[arg-type] @classmethod def convert_list(cls, value: list[Any]) -> list[Self]: return [ cls.from_dict(x) if isinstance(x, dict) else cls.from_pb(x) for x in value ] def converter_field(*, converter: Callable[[Any], _V], **kwargs: Any) -> _V: metadata = kwargs.pop("metadata", {}) metadata["converter"] = converter return cast( "_V", field(metadata=metadata, **kwargs), # pylint: disable=invalid-field-call ) @dataclass(frozen=True, order=True) class APIVersion(APIModelBase): major: int = 0 minor: int = 0 class BluetoothProxyFeature(enum.IntFlag): PASSIVE_SCAN = 1 << 0 ACTIVE_CONNECTIONS = 1 << 1 REMOTE_CACHING = 1 << 2 PAIRING = 1 << 3 CACHE_CLEARING = 1 << 4 RAW_ADVERTISEMENTS = 1 << 5 FEATURE_STATE_AND_MODE = 1 << 6 CONNECTION_PARAMS_SETTING = 1 << 7 class ClimateFeature(enum.IntFlag): SUPPORTS_CURRENT_TEMPERATURE = 1 << 0 SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = 1 << 1 REQUIRES_TWO_POINT_TARGET_TEMPERATURE = 1 << 2 SUPPORTS_CURRENT_HUMIDITY = 1 << 3 SUPPORTS_TARGET_HUMIDITY = 1 << 4 SUPPORTS_ACTION = 1 << 5 class BluetoothProxySubscriptionFlag(enum.IntFlag): RAW_ADVERTISEMENTS = 1 << 0 class VoiceAssistantFeature(enum.IntFlag): VOICE_ASSISTANT = 1 << 0 SPEAKER = 1 << 1 API_AUDIO = 1 << 2 TIMERS = 1 << 3 ANNOUNCE = 1 << 4 START_CONVERSATION = 1 << 5 MULTI_CHANNEL_AUDIO = 1 << 6 class ZWaveProxyFeature(enum.IntFlag): ENABLED = 1 << 0 class ZWaveProxyRequestType(APIIntEnum): SUBSCRIBE = 0 UNSUBSCRIBE = 1 HOME_ID_CHANGE = 2 @_frozen_dataclass_decorator class ZWaveProxyFrame(APIModelBase): data: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call @_frozen_dataclass_decorator class ZWaveProxyRequest(APIModelBase): type: ZWaveProxyRequestType = ZWaveProxyRequestType.SUBSCRIBE data: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call class InfraredCapability(enum.IntFlag): TRANSMITTER = 1 << 0 RECEIVER = 1 << 1 @_frozen_dataclass_decorator class InfraredRFReceiveEvent(APIModelBase): device_id: int = 0 key: int = 0 timings: list[int] = field(default_factory=list) # pylint: disable=invalid-field-call class RadioFrequencyModulation(enum.IntEnum): OOK = 0 class RadioFrequencyCapability(enum.IntFlag): TRANSMITTER = 1 << 0 RECEIVER = 1 << 1 class VoiceAssistantSubscriptionFlag(enum.IntFlag): API_AUDIO = 1 << 2 @_frozen_dataclass_decorator class AreaInfo(APIModelBase): area_id: int = 0 name: str = "" @classmethod def convert(cls, value: Any) -> AreaInfo: if isinstance(value, dict): return cls.from_dict(value) return cls.from_pb(value) @_frozen_dataclass_decorator class SubDeviceInfo(APIModelBase): device_id: int = 0 name: str = "" area_id: int = 0 class SerialProxyPortType(APIIntEnum): TTL = 0 RS232 = 1 RS485 = 2 @_frozen_dataclass_decorator class SerialProxyInfo(APIModelBase): name: str = "" port_type: SerialProxyPortType | None = converter_field( default=SerialProxyPortType.TTL, converter=SerialProxyPortType.convert ) @_frozen_dataclass_decorator class DeviceInfo(APIModelBase): uses_password: bool = False name: str = "" friendly_name: str = "" mac_address: str = "" compilation_time: str = "" model: str = "" manufacturer: str = "" has_deep_sleep: bool = False esphome_version: str = "" project_name: str = "" project_version: str = "" webserver_port: int = 0 legacy_voice_assistant_version: int = 0 voice_assistant_feature_flags: int = 0 legacy_bluetooth_proxy_version: int = 0 bluetooth_proxy_feature_flags: int = 0 zwave_proxy_feature_flags: int = 0 zwave_home_id: int = 0 suggested_area: str = "" bluetooth_mac_address: str = "" api_encryption_supported: bool = False devices: list[SubDeviceInfo] = converter_field( default_factory=list, converter=SubDeviceInfo.convert_list ) areas: list[AreaInfo] = converter_field( default_factory=list, converter=AreaInfo.convert_list ) area: AreaInfo = converter_field( default_factory=AreaInfo, converter=AreaInfo.convert ) serial_proxies: list[SerialProxyInfo] = converter_field( default_factory=list, converter=SerialProxyInfo.convert_list ) def bluetooth_proxy_feature_flags_compat(self, api_version: APIVersion) -> int: if api_version < APIVersion(1, 9): flags: int = 0 if self.legacy_bluetooth_proxy_version >= 1: flags |= BluetoothProxyFeature.PASSIVE_SCAN if self.legacy_bluetooth_proxy_version >= 2: flags |= BluetoothProxyFeature.ACTIVE_CONNECTIONS if self.legacy_bluetooth_proxy_version >= 3: flags |= BluetoothProxyFeature.REMOTE_CACHING if self.legacy_bluetooth_proxy_version >= 4: flags |= BluetoothProxyFeature.PAIRING if self.legacy_bluetooth_proxy_version >= 5: flags |= BluetoothProxyFeature.CACHE_CLEARING return flags return self.bluetooth_proxy_feature_flags def voice_assistant_feature_flags_compat(self, api_version: APIVersion) -> int: if api_version < APIVersion(1, 10): flags: int = 0 if self.legacy_voice_assistant_version >= 1: flags |= VoiceAssistantFeature.VOICE_ASSISTANT if self.legacy_voice_assistant_version == 2: flags |= VoiceAssistantFeature.SPEAKER return flags return self.voice_assistant_feature_flags def zwave_proxy_feature_flags_compat( self, api_version: APIVersion, # noqa: ARG002 ) -> int: return self.zwave_proxy_feature_flags class EntityCategory(APIIntEnum): NONE = 0 CONFIG = 1 DIAGNOSTIC = 2 @_frozen_dataclass_decorator class EntityInfo(APIModelBase): object_id: str = "" key: int = 0 name: str = "" disabled_by_default: bool = False icon: str = "" entity_category: EntityCategory | None = converter_field( default=EntityCategory.NONE, converter=EntityCategory.convert ) device_id: int = 0 @_frozen_dataclass_decorator class EntityState(APIModelBase): key: int = 0 device_id: int = 0 @_frozen_dataclass_decorator class CommandProtoMessage(APIModelBase): key: int = 0 device_id: int = 0 # ==================== BINARY SENSOR ==================== @_frozen_dataclass_decorator class BinarySensorInfo(EntityInfo): device_class: str = "" is_status_binary_sensor: bool = False @_frozen_dataclass_decorator class BinarySensorState(EntityState): state: bool = False missing_state: bool = False # ==================== COVER ==================== @_frozen_dataclass_decorator class CoverInfo(EntityInfo): assumed_state: bool = False supports_stop: bool = False supports_position: bool = False supports_tilt: bool = False device_class: str = "" class LegacyCoverState(APIIntEnum): OPEN = 0 CLOSED = 1 class LegacyCoverCommand(APIIntEnum): OPEN = 0 CLOSE = 1 STOP = 2 class CoverOperation(APIIntEnum): IDLE = 0 IS_OPENING = 1 IS_CLOSING = 2 @_frozen_dataclass_decorator class CoverState(EntityState): legacy_state: LegacyCoverState | None = converter_field( default=LegacyCoverState.OPEN, converter=LegacyCoverState.convert ) position: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) tilt: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) current_operation: CoverOperation | None = converter_field( default=CoverOperation.IDLE, converter=CoverOperation.convert ) def is_closed(self, api_version: APIVersion) -> bool: if api_version < APIVersion(1, 1): return self.legacy_state == LegacyCoverState.CLOSED return self.position == 0.0 # ==================== EVENT ================== @_frozen_dataclass_decorator class EventInfo(EntityInfo): device_class: str = "" event_types: list[str] = converter_field(default_factory=list, converter=list) @_frozen_dataclass_decorator class Event(EntityState): event_type: str = "" # ==================== FAN ==================== @_frozen_dataclass_decorator class FanInfo(EntityInfo): supports_oscillation: bool = False supports_speed: bool = False supports_direction: bool = False supported_speed_count: int = 0 supported_preset_modes: list[str] = converter_field( default_factory=list, converter=list ) class FanSpeed(APIIntEnum): LOW = 0 MEDIUM = 1 HIGH = 2 class FanDirection(APIIntEnum): FORWARD = 0 REVERSE = 1 @_frozen_dataclass_decorator class FanState(EntityState): state: bool = False oscillating: bool = False speed: FanSpeed | None = converter_field( default=FanSpeed.LOW, converter=FanSpeed.convert ) speed_level: int = 0 direction: FanDirection | None = converter_field( default=FanDirection.FORWARD, converter=FanDirection.convert ) preset_mode: str = "" # ==================== LIGHT ==================== class LightColorCapability(enum.IntFlag): ON_OFF = 1 << 0 BRIGHTNESS = 1 << 1 WHITE = 1 << 2 COLOR_TEMPERATURE = 1 << 3 COLD_WARM_WHITE = 1 << 4 RGB = 1 << 5 class ColorMode(APIIntEnum): UNKNOWN = 0 ON_OFF = 1 LEGACY_BRIGHTNESS = 2 BRIGHTNESS = 3 WHITE = 7 COLOR_TEMPERATURE = 11 COLD_WARM_WHITE = 19 RGB = 35 RGB_WHITE = 39 RGB_COLOR_TEMPERATURE = 47 RGB_COLD_WARM_WHITE = 51 @_frozen_dataclass_decorator class LightInfo(EntityInfo): supported_color_modes: list[ColorMode] = converter_field( default_factory=list, converter=ColorMode.convert_list ) min_mireds: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) max_mireds: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) effects: list[str] = converter_field(default_factory=list, converter=list) # deprecated, do not use legacy_supports_brightness: bool = False legacy_supports_rgb: bool = False legacy_supports_white_value: bool = False legacy_supports_color_temperature: bool = False def supported_color_modes_compat(self, api_version: APIVersion) -> list[ColorMode]: if api_version < APIVersion(1, 6): key = ( self.legacy_supports_brightness, self.legacy_supports_rgb, self.legacy_supports_white_value, self.legacy_supports_color_temperature, ) # Map (brightness, rgb, white, color_temp) flag tuples to color modes. modes_map = { (False, False, False, False): [LightColorCapability.ON_OFF], (True, False, False, False): [ LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS ], (True, False, False, True): [ LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.COLOR_TEMPERATURE ], (True, True, False, False): [ LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.RGB ], (True, True, True, False): [ LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.RGB | LightColorCapability.WHITE ], (True, True, False, True): [ LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.RGB | LightColorCapability.COLOR_TEMPERATURE ], (True, True, True, True): [ LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.RGB | LightColorCapability.WHITE | LightColorCapability.COLOR_TEMPERATURE ], } return cast("list[ColorMode]", modes_map[key]) if key in modes_map else [] return self.supported_color_modes @_frozen_dataclass_decorator class LightState(EntityState): state: bool = False brightness: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) color_mode: ColorMode = ColorMode.UNKNOWN color_brightness: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) red: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) green: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) blue: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) white: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) color_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) cold_white: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) warm_white: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) effect: str = "" # ==================== SENSOR ==================== class SensorStateClass(APIIntEnum): NONE = 0 MEASUREMENT = 1 TOTAL_INCREASING = 2 TOTAL = 3 MEASUREMENT_ANGLE = 4 class LastResetType(APIIntEnum): NONE = 0 NEVER = 1 AUTO = 2 @_frozen_dataclass_decorator class SensorInfo(EntityInfo): device_class: str = "" unit_of_measurement: str = "" accuracy_decimals: int = 0 force_update: bool = False state_class: SensorStateClass | None = converter_field( default=SensorStateClass.NONE, converter=SensorStateClass.convert ) legacy_last_reset_type: LastResetType | None = converter_field( default=LastResetType.NONE, converter=LastResetType.convert ) @_frozen_dataclass_decorator class SensorState(EntityState): state: float = 0.0 missing_state: bool = False # ==================== SWITCH ==================== @_frozen_dataclass_decorator class SwitchInfo(EntityInfo): assumed_state: bool = False device_class: str = "" @_frozen_dataclass_decorator class SwitchState(EntityState): state: bool = False # ==================== TEXT SENSOR ==================== @_frozen_dataclass_decorator class TextSensorInfo(EntityInfo): device_class: str = "" @_frozen_dataclass_decorator class TextSensorState(EntityState): state: str = "" missing_state: bool = False # ==================== CAMERA ==================== @_frozen_dataclass_decorator class CameraInfo(EntityInfo): pass @_frozen_dataclass_decorator class CameraState(EntityState): data: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call # ==================== CLIMATE ==================== class ClimateMode(APIIntEnum): OFF = 0 HEAT_COOL = 1 COOL = 2 HEAT = 3 FAN_ONLY = 4 DRY = 5 AUTO = 6 class ClimateFanMode(APIIntEnum): ON = 0 OFF = 1 AUTO = 2 LOW = 3 MEDIUM = 4 HIGH = 5 MIDDLE = 6 FOCUS = 7 DIFFUSE = 8 QUIET = 9 class ClimateSwingMode(APIIntEnum): OFF = 0 BOTH = 1 VERTICAL = 2 HORIZONTAL = 3 class ClimateAction(APIIntEnum): OFF = 0 COOLING = 2 HEATING = 3 IDLE = 4 DRYING = 5 FAN = 6 DEFROSTING = 7 class ClimatePreset(APIIntEnum): NONE = 0 HOME = 1 AWAY = 2 BOOST = 3 COMFORT = 4 ECO = 5 SLEEP = 6 ACTIVITY = 7 class TemperatureUnit(APIIntEnum): CELSIUS = 0 FAHRENHEIT = 1 KELVIN = 2 @_frozen_dataclass_decorator class ClimateInfo(EntityInfo): feature_flags: int = 0 supports_current_temperature: bool = False supports_two_point_target_temperature: bool = False supported_modes: list[ClimateMode] = converter_field( default_factory=list, converter=ClimateMode.convert_list ) visual_min_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) visual_max_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) visual_target_temperature_step: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) visual_current_temperature_step: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) temperature_unit: TemperatureUnit | None = converter_field( default=TemperatureUnit.CELSIUS, converter=TemperatureUnit.convert, ) legacy_supports_away: bool = False supports_action: bool = False supported_fan_modes: list[ClimateFanMode] = converter_field( default_factory=list, converter=ClimateFanMode.convert_list ) supported_swing_modes: list[ClimateSwingMode] = converter_field( default_factory=list, converter=ClimateSwingMode.convert_list ) supported_custom_fan_modes: list[str] = converter_field( default_factory=list, converter=list ) supported_presets: list[ClimatePreset] = converter_field( default_factory=list, converter=ClimatePreset.convert_list ) supported_custom_presets: list[str] = converter_field( default_factory=list, converter=list ) supports_current_humidity: bool = False supports_target_humidity: bool = False visual_min_humidity: float = 0 visual_max_humidity: float = 0 def supported_feature_flags_compat(self, api_version: APIVersion) -> int: if api_version < APIVersion(1, 13): flags: int = 0 if self.supports_current_temperature: flags |= ClimateFeature.SUPPORTS_CURRENT_TEMPERATURE if self.supports_two_point_target_temperature: # Use REQUIRES_TWO_POINT_TARGET_TEMPERATURE to mimic previous behavior flags |= ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE if self.supports_current_humidity: flags |= ClimateFeature.SUPPORTS_CURRENT_HUMIDITY if self.supports_target_humidity: flags |= ClimateFeature.SUPPORTS_TARGET_HUMIDITY if self.supports_action: flags |= ClimateFeature.SUPPORTS_ACTION return flags return self.feature_flags def supported_presets_compat(self, api_version: APIVersion) -> list[ClimatePreset]: if api_version < APIVersion(1, 5): return ( [ClimatePreset.HOME, ClimatePreset.AWAY] if self.legacy_supports_away else [] ) return self.supported_presets @_frozen_dataclass_decorator class ClimateState(EntityState): mode: ClimateMode | None = converter_field( default=ClimateMode.OFF, converter=ClimateMode.convert ) action: ClimateAction | None = converter_field( default=ClimateAction.OFF, converter=ClimateAction.convert ) current_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature_low: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature_high: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) unused_legacy_away: bool = False fan_mode: ClimateFanMode | None = converter_field( default=ClimateFanMode.ON, converter=ClimateFanMode.convert ) swing_mode: ClimateSwingMode | None = converter_field( default=ClimateSwingMode.OFF, converter=ClimateSwingMode.convert ) custom_fan_mode: str = "" preset: ClimatePreset | None = converter_field( default=ClimatePreset.NONE, converter=ClimatePreset.convert ) custom_preset: str = "" current_humidity: float = 0 target_humidity: float = 0 def preset_compat(self, api_version: APIVersion) -> ClimatePreset | None: if api_version < APIVersion(1, 5): return ClimatePreset.AWAY if self.unused_legacy_away else ClimatePreset.HOME return self.preset # ==================== NUMBER ==================== class NumberMode(APIIntEnum): AUTO = 0 BOX = 1 SLIDER = 2 @_frozen_dataclass_decorator class NumberInfo(EntityInfo): min_value: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) max_value: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) step: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) unit_of_measurement: str = "" mode: NumberMode | None = converter_field( default=NumberMode.AUTO, converter=NumberMode.convert ) device_class: str = "" @_frozen_dataclass_decorator class NumberState(EntityState): state: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) missing_state: bool = False # ==================== DATETIME DATE ==================== @_frozen_dataclass_decorator class DateInfo(EntityInfo): pass @_frozen_dataclass_decorator class DateState(EntityState): missing_state: bool = False year: int = 0 month: int = 0 day: int = 0 # ==================== DATETIME TIME ==================== @_frozen_dataclass_decorator class TimeInfo(EntityInfo): pass @_frozen_dataclass_decorator class TimeState(EntityState): missing_state: bool = False hour: int = 0 minute: int = 0 second: int = 0 # ==================== DATETIME DATETIME ==================== @_frozen_dataclass_decorator class DateTimeInfo(EntityInfo): pass @_frozen_dataclass_decorator class DateTimeState(EntityState): missing_state: bool = False epoch_seconds: int = 0 # ==================== SELECT ==================== @_frozen_dataclass_decorator class SelectInfo(EntityInfo): options: list[str] = converter_field(default_factory=list, converter=list) @_frozen_dataclass_decorator class SelectState(EntityState): state: str = "" missing_state: bool = False # ==================== SIREN ==================== @_frozen_dataclass_decorator class SirenInfo(EntityInfo): tones: list[str] = converter_field(default_factory=list, converter=list) supports_volume: bool = False supports_duration: bool = False @_frozen_dataclass_decorator class SirenState(EntityState): state: bool = False # ==================== BUTTON ==================== @_frozen_dataclass_decorator class ButtonInfo(EntityInfo): device_class: str = "" # ==================== LOCK ==================== class LockState(APIIntEnum): NONE = 0 LOCKED = 1 UNLOCKED = 2 JAMMED = 3 LOCKING = 4 UNLOCKING = 5 OPENING = 6 OPEN = 7 class LockCommand(APIIntEnum): UNLOCK = 0 LOCK = 1 OPEN = 2 @_frozen_dataclass_decorator class LockInfo(EntityInfo): supports_open: bool = False assumed_state: bool = False requires_code: bool = False code_format: str = "" @_frozen_dataclass_decorator class LockEntityState(EntityState): state: LockState | None = converter_field( default=LockState.NONE, converter=LockState.convert ) # ==================== VALVE ==================== @_frozen_dataclass_decorator class ValveInfo(EntityInfo): device_class: str = "" assumed_state: bool = False supports_stop: bool = False supports_position: bool = False class ValveOperation(APIIntEnum): IDLE = 0 IS_OPENING = 1 IS_CLOSING = 2 @_frozen_dataclass_decorator class ValveState(EntityState): position: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) current_operation: ValveOperation | None = converter_field( default=ValveOperation.IDLE, converter=ValveOperation.convert ) # ==================== MEDIA PLAYER ==================== class MediaPlayerEntityFeature(enum.IntFlag): """Supported features of the media player entity.""" PAUSE = 1 << 0 SEEK = 1 << 1 VOLUME_SET = 1 << 2 VOLUME_MUTE = 1 << 3 PREVIOUS_TRACK = 1 << 4 NEXT_TRACK = 1 << 5 TURN_ON = 1 << 7 TURN_OFF = 1 << 8 PLAY_MEDIA = 1 << 9 VOLUME_STEP = 1 << 10 SELECT_SOURCE = 1 << 11 STOP = 1 << 12 CLEAR_PLAYLIST = 1 << 13 PLAY = 1 << 14 SHUFFLE_SET = 1 << 15 SELECT_SOUND_MODE = 1 << 16 BROWSE_MEDIA = 1 << 17 REPEAT_SET = 1 << 18 GROUPING = 1 << 19 MEDIA_ANNOUNCE = 1 << 20 MEDIA_ENQUEUE = 1 << 21 SEARCH_MEDIA = 1 << 22 class MediaPlayerState(APIIntEnum): NONE = 0 IDLE = 1 PLAYING = 2 PAUSED = 3 ANNOUNCING = 4 OFF = 5 ON = 6 class MediaPlayerCommand(APIIntEnum): PLAY = 0 PAUSE = 1 STOP = 2 MUTE = 3 UNMUTE = 4 TOGGLE = 5 VOLUME_UP = 6 VOLUME_DOWN = 7 ENQUEUE = 8 REPEAT_ONE = 9 REPEAT_OFF = 10 CLEAR_PLAYLIST = 11 TURN_ON = 12 TURN_OFF = 13 class MediaPlayerFormatPurpose(APIIntEnum): DEFAULT = 0 ANNOUNCEMENT = 1 @_frozen_dataclass_decorator class MediaPlayerSupportedFormat(APIModelBase): format: str sample_rate: int num_channels: int purpose: MediaPlayerFormatPurpose | None = converter_field( default=MediaPlayerFormatPurpose.DEFAULT, converter=MediaPlayerFormatPurpose.convert, ) sample_bytes: int = 0 @_frozen_dataclass_decorator class MediaPlayerInfo(EntityInfo): supports_pause: bool = False supported_formats: list[MediaPlayerSupportedFormat] = converter_field( default_factory=list, converter=MediaPlayerSupportedFormat.convert_list ) feature_flags: int = 0 def feature_flags_compat(self, api_version: APIVersion) -> int: if api_version < APIVersion(1, 11): flags = ( MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE ) if self.supports_pause: flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY return flags return self.feature_flags @_frozen_dataclass_decorator class MediaPlayerEntityState(EntityState): state: MediaPlayerState | None = converter_field( default=MediaPlayerState.NONE, converter=MediaPlayerState.convert ) volume: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) muted: bool = False # ==================== ALARM CONTROL PANEL ==================== class AlarmControlPanelState(APIIntEnum): DISARMED = 0 ARMED_HOME = 1 ARMED_AWAY = 2 ARMED_NIGHT = 3 ARMED_VACATION = 4 ARMED_CUSTOM_BYPASS = 5 PENDING = 6 ARMING = 7 DISARMING = 8 TRIGGERED = 9 class AlarmControlPanelCommand(APIIntEnum): DISARM = 0 ARM_AWAY = 1 ARM_HOME = 2 ARM_NIGHT = 3 ARM_VACATION = 4 ARM_CUSTOM_BYPASS = 5 TRIGGER = 6 class AlarmControlPanelEntityFeature(enum.IntFlag): """Supported features of the alarm control panel entity.""" ARM_HOME = 1 << 0 ARM_AWAY = 1 << 1 ARM_NIGHT = 1 << 2 TRIGGER = 1 << 3 ARM_CUSTOM_BYPASS = 1 << 4 ARM_VACATION = 1 << 5 @_frozen_dataclass_decorator class AlarmControlPanelInfo(EntityInfo): supported_features: int = 0 requires_code: bool = False requires_code_to_arm: bool = False @_frozen_dataclass_decorator class AlarmControlPanelEntityState(EntityState): state: AlarmControlPanelState | None = converter_field( default=AlarmControlPanelState.DISARMED, converter=AlarmControlPanelState.convert, ) # ==================== WATER HEATER ==================== class WaterHeaterMode(APIIntEnum): OFF = 0 ECO = 1 ELECTRIC = 2 PERFORMANCE = 3 HIGH_DEMAND = 4 HEAT_PUMP = 5 GAS = 6 class WaterHeaterFeature(enum.IntFlag): """ESPHome water heater feature flags (WaterHeaterFeature).""" SUPPORTS_CURRENT_TEMPERATURE = 1 << 0 SUPPORTS_TARGET_TEMPERATURE = 1 << 1 SUPPORTS_OPERATION_MODE = 1 << 2 SUPPORTS_AWAY_MODE = 1 << 3 SUPPORTS_ON_OFF = 1 << 4 SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = 1 << 5 class WaterHeaterCommandField(enum.IntFlag): """Bitmask for has_fields in WaterHeaterCommandRequest.""" MODE = 1 << 0 TARGET_TEMPERATURE = 1 << 1 STATE = 1 << 2 TARGET_TEMPERATURE_LOW = 1 << 3 TARGET_TEMPERATURE_HIGH = 1 << 4 ON_STATE = 1 << 5 AWAY_STATE = 1 << 6 class WaterHeaterStateFlag(enum.IntFlag): """Bitmask for state field in WaterHeaterStateResponse/CommandRequest.""" AWAY = 1 << 0 ON = 1 << 1 @_frozen_dataclass_decorator class WaterHeaterInfo(EntityInfo): min_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) max_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature_step: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) temperature_unit: TemperatureUnit | None = converter_field( default=TemperatureUnit.CELSIUS, converter=TemperatureUnit.convert, ) supported_modes: list[WaterHeaterMode] = converter_field( default_factory=list, converter=WaterHeaterMode.convert_list ) supported_features: int = 0 @_frozen_dataclass_decorator class WaterHeaterState(EntityState): current_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature_low: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) target_temperature_high: float = converter_field( default=0.0, converter=fix_float_single_double_conversion ) mode: WaterHeaterMode | None = converter_field( default=WaterHeaterMode.OFF, converter=WaterHeaterMode.convert ) state: int = 0 # ==================== TEXT ==================== class TextMode(APIIntEnum): TEXT = 0 PASSWORD = 1 @_frozen_dataclass_decorator class TextInfo(EntityInfo): min_length: int = 0 max_length: int = 255 pattern: str = "" mode: TextMode | None = converter_field( default=TextMode.TEXT, converter=TextMode.convert ) @_frozen_dataclass_decorator class TextState(EntityState): state: str = "" missing_state: bool = False # ==================== UPDATE ==================== class UpdateCommand(APIIntEnum): NONE = 0 INSTALL = 1 CHECK = 2 @_frozen_dataclass_decorator class UpdateInfo(EntityInfo): device_class: str = "" @_frozen_dataclass_decorator class UpdateState(EntityState): missing_state: bool = False in_progress: bool = False has_progress: bool = False progress: float = 0.0 current_version: str = "" latest_version: str = "" title: str = "" release_summary: str = "" release_url: str = "" # ==================== INFRARED ==================== @_frozen_dataclass_decorator class InfraredInfo(EntityInfo): capabilities: int = 0 receiver_frequency: int = 0 # ==================== RADIO FREQUENCY ==================== @_frozen_dataclass_decorator class RadioFrequencyInfo(EntityInfo): capabilities: int = 0 frequency_min: int = 0 # Minimum tunable frequency in Hz (0 = unspecified; equal to frequency_max → fixed) frequency_max: int = 0 # Maximum tunable frequency in Hz (0 = unspecified) supported_modulations: int = ( 0 # Bitmask: bit N set means RadioFrequencyModulation(N) is supported ) def supports_modulation(self, modulation: RadioFrequencyModulation) -> bool: """Return True if the given modulation type is supported. The supported_modulations bitmask uses bit N to represent RadioFrequencyModulation value N (e.g. OOK=0 → bit 0). """ return bool(self.supported_modulations & (1 << int(modulation))) # ==================== SERIAL PROXY ==================== class SerialProxyParity(APIIntEnum): NONE = 0 EVEN = 1 ODD = 2 class SerialProxyRequestType(APIIntEnum): SUBSCRIBE = 0 UNSUBSCRIBE = 1 FLUSH = 2 class SerialProxyStatus(APIIntEnum): OK = 0 ASSUMED_SUCCESS = 1 ERROR = 2 TIMEOUT = 3 NOT_SUPPORTED = 4 @_frozen_dataclass_decorator class SerialProxyDataReceived(APIModelBase): instance: int = 0 data: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call @_frozen_dataclass_decorator class SerialProxyRequestResponse(APIModelBase): instance: int = 0 type: SerialProxyRequestType | None = converter_field( default=SerialProxyRequestType.SUBSCRIBE, converter=SerialProxyRequestType.convert, ) status: SerialProxyStatus | None = converter_field( default=SerialProxyStatus.OK, converter=SerialProxyStatus.convert ) error_message: str = "" @_frozen_dataclass_decorator class SerialProxyModemPins(APIModelBase): instance: int = 0 line_states: int = 0 # ==================== INFO MAP ==================== COMPONENT_TYPE_TO_INFO: dict[str, type[EntityInfo]] = { "binary_sensor": BinarySensorInfo, "cover": CoverInfo, "fan": FanInfo, "light": LightInfo, "sensor": SensorInfo, "switch": SwitchInfo, "text_sensor": TextSensorInfo, "camera": CameraInfo, "climate": ClimateInfo, "number": NumberInfo, "date": DateInfo, "datetime": DateTimeInfo, "select": SelectInfo, "siren": SirenInfo, "button": ButtonInfo, "lock": LockInfo, "media_player": MediaPlayerInfo, "alarm_control_panel": AlarmControlPanelInfo, "text": TextInfo, "time": TimeInfo, "valve": ValveInfo, "event": EventInfo, "update": UpdateInfo, "water_heater": WaterHeaterInfo, "infrared": InfraredInfo, "radio_frequency": RadioFrequencyInfo, } # ==================== USER-DEFINED SERVICES ==================== def _convert_homeassistant_service_map( value: dict[str, str] | Iterable[HomeassistantServiceMap], ) -> dict[str, str]: if isinstance(value, dict): # already a dict, don't convert return value return {v.key: v.value for v in value} # type: ignore[union-attr] @_frozen_dataclass_decorator class HomeassistantServiceCall(APIModelBase): service: str = "" is_event: bool = False data: dict[str, str] = converter_field( default_factory=dict, converter=_convert_homeassistant_service_map ) data_template: dict[str, str] = converter_field( default_factory=dict, converter=_convert_homeassistant_service_map ) variables: dict[str, str] = converter_field( default_factory=dict, converter=_convert_homeassistant_service_map ) call_id: int = 0 # Call ID for response tracking wants_response: bool = False response_template: str = "" # Optional Jinja template for response processing @_frozen_dataclass_decorator class HomeassistantActionResponse(APIModelBase): call_id: int = 0 # Call ID that matches the original request response_data: bytes = field( default_factory=bytes ) # Response data from Home Assistant success: bool = False # Whether the service call was successful error_message: str = "" # Error message if the call failed class UserServiceArgType(APIIntEnum): BOOL = 0 INT = 1 FLOAT = 2 STRING = 3 BOOL_ARRAY = 4 INT_ARRAY = 5 FLOAT_ARRAY = 6 STRING_ARRAY = 7 class SupportsResponseType(APIIntEnum): NONE = 0 OPTIONAL = 1 ONLY = 2 STATUS = 100 @_frozen_dataclass_decorator class UserServiceArg(APIModelBase): name: str = "" type: UserServiceArgType | None = converter_field( default=UserServiceArgType.BOOL, converter=UserServiceArgType.convert ) @classmethod def convert_list(cls, value: list[Any]) -> list[UserServiceArg]: ret = [] for x in value: if isinstance(x, dict): if "type_" in x and "type" not in x: x = {**x, "type": x["type_"]} ret.append(UserServiceArg.from_dict(x)) else: ret.append(UserServiceArg.from_pb(x)) return ret @_frozen_dataclass_decorator class UserService(APIModelBase): name: str = "" key: int = 0 args: list[UserServiceArg] = converter_field( default_factory=list, converter=UserServiceArg.convert_list ) supports_response: SupportsResponseType | None = converter_field( default=SupportsResponseType.NONE, converter=SupportsResponseType.convert ) @_frozen_dataclass_decorator class ExecuteServiceResponse(APIModelBase): call_id: int = 0 # Call ID that matches the original request success: bool = False # Whether the service execution succeeded error_message: str = "" # Error message if success = false response_data: bytes = field( default_factory=bytes ) # JSON response data from ESPHome # ==================== BLUETOOTH ==================== def _join_split_uuid(value: list[int]) -> str: """Convert a high/low uuid into a single string.""" # The firmware omits the whole split-uuid array when the UUID is all-zero # (fixed_array_skip_zero in api.proto), so a short array means the missing # words are zero. high = value[0] if len(value) > 0 else 0 low = value[1] if len(value) > 1 else 0 return _join_split_uuid_high_low(high, low) @lru_cache(maxsize=256) def _join_split_uuid_high_low(high: int, low: int) -> str: return str(UUID(int=(high << 64) | low)) @lru_cache(maxsize=256) def _convert_short_uuid_to_128bit(short_uuid: int) -> str: """Convert a 16-bit or 32-bit UUID to 128-bit string format.""" return f"{short_uuid:08x}-0000-1000-8000-00805f9b34fb" def _convert_bluetooth_uuid(value: Any) -> str: """Convert a Bluetooth UUID from protobuf format to string. Handles efficient UUID field (short_uuid) for v1.12+ clients, falling back to the standard 128-bit UUID array format. """ # Check for efficient UUID field (v1.12+) # Use walrus operator to handle protobuf's on-demand construction if short_uuid := value.short_uuid: return _convert_short_uuid_to_128bit(short_uuid) # Fall back to 128-bit UUID return _join_split_uuid(value.uuid) def _uuid_converter(uuid: str) -> str: return ( f"0000{uuid[2:].lower()}-0000-1000-8000-00805f9b34fb" if len(uuid) < 8 else uuid.lower() ) _cached_uuid_converter = lru_cache(maxsize=128)(_uuid_converter) @_dataclass_decorator class BluetoothLEAdvertisement: address: int rssi: int address_type: int name: str service_uuids: list[str] service_data: dict[str, bytes] manufacturer_data: dict[int, bytes] @classmethod def from_pb( # type: ignore[misc] cls: BluetoothLEAdvertisement, data: BluetoothLEAdvertisementResponse ) -> BluetoothLEAdvertisement: _uuid_convert = _cached_uuid_converter if raw_manufacturer_data := data.manufacturer_data: if raw_manufacturer_data[0].data: manufacturer_data = { int(v.uuid, 16): v.data for v in raw_manufacturer_data } else: # Legacy data manufacturer_data = { int(v.uuid, 16): bytes(v.legacy_data) for v in raw_manufacturer_data } else: manufacturer_data = {} if raw_service_data := data.service_data: if raw_service_data[0].data: service_data = {_uuid_convert(v.uuid): v.data for v in raw_service_data} else: # Legacy data service_data = { _uuid_convert(v.uuid): bytes(v.legacy_data) for v in raw_service_data } else: service_data = {} if raw_service_uuids := data.service_uuids: service_uuids = [_uuid_convert(v) for v in raw_service_uuids] else: service_uuids = [] return cls( # type: ignore[operator, no-any-return] address=data.address, rssi=data.rssi, address_type=data.address_type, name=data.name.decode("utf-8", errors="replace"), service_uuids=service_uuids, service_data=service_data, manufacturer_data=manufacturer_data, ) @_frozen_dataclass_decorator class BluetoothDeviceConnection(APIModelBase): address: int = 0 connected: bool = False mtu: int = 0 error: int = 0 @_frozen_dataclass_decorator class BluetoothDevicePairing(APIModelBase): address: int = 0 paired: bool = False error: int = 0 @_frozen_dataclass_decorator class BluetoothDeviceUnpairing(APIModelBase): address: int = 0 success: bool = False error: int = 0 @_frozen_dataclass_decorator class BluetoothDeviceClearCache(APIModelBase): address: int = 0 success: bool = False error: int = 0 @_frozen_dataclass_decorator class BluetoothGATTRead(APIModelBase): address: int = 0 handle: int = 0 data: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call @_frozen_dataclass_decorator class BluetoothGATTDescriptor(APIModelBase): uuid: str = "" handle: int = 0 @classmethod def from_pb(cls, data: Any) -> BluetoothGATTDescriptor: # If data is already a model instance, return it if isinstance(data, cls): return data # Convert UUID using the common function return cls(uuid=_convert_bluetooth_uuid(data), handle=data.handle) # type: ignore[call-arg] @classmethod def from_dict( cls, data: dict[str, Any], *, ignore_missing: bool = True ) -> BluetoothGATTDescriptor: # Handle UUID conversion from list to string if "uuid" in data and isinstance(data["uuid"], list): data = data.copy() data["uuid"] = _join_split_uuid(data["uuid"]) return APIModelBase.from_dict.__func__(cls, data, ignore_missing=ignore_missing) # type: ignore[attr-defined, no-any-return] @_frozen_dataclass_decorator class BluetoothGATTCharacteristic(APIModelBase): uuid: str = "" handle: int = 0 properties: int = 0 descriptors: list[BluetoothGATTDescriptor] = converter_field( default_factory=list, converter=BluetoothGATTDescriptor.convert_list ) @classmethod def from_pb(cls, data: Any) -> BluetoothGATTCharacteristic: # If data is already a model instance, return it if isinstance(data, cls): return data # Convert UUID using the common function return cls( # type: ignore[call-arg] uuid=_convert_bluetooth_uuid(data), handle=data.handle, properties=data.properties, descriptors=BluetoothGATTDescriptor.convert_list(data.descriptors), ) @classmethod def from_dict( cls, data: dict[str, Any], *, ignore_missing: bool = True ) -> BluetoothGATTCharacteristic: # Handle UUID conversion from list to string if "uuid" in data and isinstance(data["uuid"], list): data = data.copy() data["uuid"] = _join_split_uuid(data["uuid"]) return APIModelBase.from_dict.__func__(cls, data, ignore_missing=ignore_missing) # type: ignore[attr-defined, no-any-return] @_frozen_dataclass_decorator class BluetoothGATTService(APIModelBase): uuid: str = "" handle: int = 0 characteristics: list[BluetoothGATTCharacteristic] = converter_field( default_factory=list, converter=BluetoothGATTCharacteristic.convert_list ) @classmethod def from_pb(cls, data: Any) -> BluetoothGATTService: # If data is already a model instance, return it if isinstance(data, cls): return data # Convert UUID using the common function return cls( # type: ignore[call-arg] uuid=_convert_bluetooth_uuid(data), handle=data.handle, characteristics=BluetoothGATTCharacteristic.convert_list( data.characteristics ), ) @classmethod def from_dict( cls, data: dict[str, Any], *, ignore_missing: bool = True ) -> BluetoothGATTService: # Handle UUID conversion from list to string if "uuid" in data and isinstance(data["uuid"], list): data = data.copy() data["uuid"] = _join_split_uuid(data["uuid"]) return APIModelBase.from_dict.__func__(cls, data, ignore_missing=ignore_missing) # type: ignore[attr-defined, no-any-return] @_frozen_dataclass_decorator class BluetoothGATTServices(APIModelBase): address: int = 0 services: list[BluetoothGATTService] = converter_field( default_factory=list, converter=BluetoothGATTService.convert_list ) @_frozen_dataclass_decorator class ESPHomeBluetoothGATTServices: address: int = 0 services: list[BluetoothGATTService] = field( # pylint: disable=invalid-field-call default_factory=list ) @_frozen_dataclass_decorator class BluetoothConnectionsFree(APIModelBase): free: int = 0 limit: int = 0 allocated: list[int] = converter_field(default_factory=list, converter=list) @_frozen_dataclass_decorator class BluetoothGATTError(APIModelBase): address: int = 0 handle: int = 0 error: int = 0 class BluetoothDeviceRequestType(APIIntEnum): CONNECT = 0 DISCONNECT = 1 PAIR = 2 UNPAIR = 3 CONNECT_V3_WITH_CACHE = 4 CONNECT_V3_WITHOUT_CACHE = 5 CLEAR_CACHE = 6 class BluetoothScannerState(APIIntEnum): IDLE = 0 STARTING = 1 RUNNING = 2 FAILED = 3 STOPPING = 4 STOPPED = 5 class BluetoothScannerMode(APIIntEnum): PASSIVE = 0 ACTIVE = 1 @_frozen_dataclass_decorator class BluetoothScannerStateResponse(APIModelBase): state: BluetoothScannerState | None = converter_field( default=BluetoothScannerState.IDLE, converter=BluetoothScannerState.convert ) mode: BluetoothScannerMode | None = converter_field( default=BluetoothScannerMode.PASSIVE, converter=BluetoothScannerMode.convert ) configured_mode: BluetoothScannerMode | None = converter_field( default=BluetoothScannerMode.PASSIVE, converter=BluetoothScannerMode.convert ) class VoiceAssistantCommandFlag(enum.IntFlag): USE_VAD = 1 << 0 USE_WAKE_WORD = 1 << 1 @_frozen_dataclass_decorator class VoiceAssistantAudioSettings(APIModelBase): noise_suppression_level: int = 0 auto_gain: int = 0 volume_multiplier: float = 1.0 @_frozen_dataclass_decorator class VoiceAssistantCommand(APIModelBase): start: bool = False conversation_id: str = "" flags: int = False audio_settings: VoiceAssistantAudioSettings = converter_field( default=VoiceAssistantAudioSettings(), converter=VoiceAssistantAudioSettings.from_pb, ) wake_word_phrase: str = "" @_frozen_dataclass_decorator class VoiceAssistantAudioData(APIModelBase): data: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call end: bool = False data2: bytes | None = None @_frozen_dataclass_decorator class VoiceAssistantAnnounceFinished(APIModelBase): success: bool = False @_frozen_dataclass_decorator class VoiceAssistantWakeWord(APIModelBase): id: str wake_word: str trained_languages: list[str] @_frozen_dataclass_decorator class VoiceAssistantExternalWakeWord(APIModelBase): id: str wake_word: str trained_languages: list[str] model_type: str model_size: int model_hash: str url: str @_frozen_dataclass_decorator class VoiceAssistantConfigurationResponse(APIModelBase): available_wake_words: list[VoiceAssistantWakeWord] = converter_field( default_factory=list, converter=VoiceAssistantWakeWord.convert_list ) active_wake_words: list[str] = converter_field(default_factory=list, converter=list) max_active_wake_words: int = 0 @_frozen_dataclass_decorator class VoiceAssistantConfigurationRequest(APIModelBase): external_wake_words: list[VoiceAssistantExternalWakeWord] = converter_field( default_factory=list, converter=VoiceAssistantExternalWakeWord.convert_list ) @_frozen_dataclass_decorator class VoiceAssistantSetConfiguration(APIModelBase): active_wake_words: list[int] = converter_field(default_factory=list, converter=list) @_frozen_dataclass_decorator class NoiseEncryptionSetKeyRequest(APIModelBase): key: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call @_frozen_dataclass_decorator class NoiseEncryptionSetKeyResponse(APIModelBase): success: bool = False class LogLevel(APIIntEnum): LOG_LEVEL_NONE = 0 LOG_LEVEL_ERROR = 1 LOG_LEVEL_WARN = 2 LOG_LEVEL_INFO = 3 LOG_LEVEL_CONFIG = 4 LOG_LEVEL_DEBUG = 5 LOG_LEVEL_VERBOSE = 6 LOG_LEVEL_VERY_VERBOSE = 7 class VoiceAssistantEventType(APIIntEnum): VOICE_ASSISTANT_ERROR = 0 VOICE_ASSISTANT_RUN_START = 1 VOICE_ASSISTANT_RUN_END = 2 VOICE_ASSISTANT_STT_START = 3 VOICE_ASSISTANT_STT_END = 4 VOICE_ASSISTANT_INTENT_START = 5 VOICE_ASSISTANT_INTENT_END = 6 VOICE_ASSISTANT_TTS_START = 7 VOICE_ASSISTANT_TTS_END = 8 VOICE_ASSISTANT_WAKE_WORD_START = 9 VOICE_ASSISTANT_WAKE_WORD_END = 10 VOICE_ASSISTANT_STT_VAD_START = 11 VOICE_ASSISTANT_STT_VAD_END = 12 VOICE_ASSISTANT_TTS_STREAM_START = 98 VOICE_ASSISTANT_TTS_STREAM_END = 99 VOICE_ASSISTANT_INTENT_PROGRESS = 100 class VoiceAssistantTimerEventType(APIIntEnum): VOICE_ASSISTANT_TIMER_STARTED = 0 VOICE_ASSISTANT_TIMER_UPDATED = 1 VOICE_ASSISTANT_TIMER_CANCELLED = 2 VOICE_ASSISTANT_TIMER_FINISHED = 3 _TYPE_TO_NAME = { BinarySensorInfo: "binary_sensor", ButtonInfo: "button", CoverInfo: "cover", FanInfo: "fan", LightInfo: "light", NumberInfo: "number", DateInfo: "date", DateTimeInfo: "datetime", SelectInfo: "select", SensorInfo: "sensor", SirenInfo: "siren", SwitchInfo: "switch", TextSensorInfo: "text_sensor", CameraInfo: "camera", ClimateInfo: "climate", LockInfo: "lock", MediaPlayerInfo: "media_player", AlarmControlPanelInfo: "alarm_control_panel", TextInfo: "text_info", TimeInfo: "time", ValveInfo: "valve", EventInfo: "event", UpdateInfo: "update", WaterHeaterInfo: "water_heater", InfraredInfo: "infrared", RadioFrequencyInfo: "radio_frequency", } def build_unique_id( formatted_mac: str, entity_info: EntityInfo, *, version: int = 1 ) -> str: """Build a unique id for an entity. Version history and rationale: ----------------------------- **Version 1 (default, legacy):** Uses `object_id` which is the entity name after applying `snake_case()` + `sanitize()` transformations. Format: `{mac}-{entity_type}-{object_id}` Example: `aabbccddeeff-sensor-temperature_sensor` This was introduced as a compromise to satisfy Home Assistant's unique ID requirements (https://developers.home-assistant.io/docs/entity_registry_index#unique-id-requirements) which list "Device Name" as an unacceptable source. For ESPHome devices, the entity name IS the unique identifier - users explicitly configure it in their YAML. The `object_id` was a compromise to derive a stable identifier from the name while technically not "using the name directly". Unfortunately, this approach had unintended consequences. The `object_id` mangles names by converting to lowercase, replacing spaces with underscores, and replacing non-ASCII characters with underscores. This causes collisions: - UTF-8 names get sanitized to underscores, causing collisions (e.g., "温度传感器" and "湿度传感器" both become "___") - Sub-devices with same-named entities collide (e.g., two "Battery" sensors on different sub-devices get the same object_id) See https://github.com/esphome/esphome-webserver/issues/160 and https://github.com/esphome/esphome-webserver/issues/153 The `object_id` concept is planned for removal. See https://github.com/esphome/backlog/issues/76 for the full history. **Version 2:** Uses the entity `name` directly without mangling. Format: `{mac}-{entity_type}-{name}` Example: `aabbccddeeff-sensor-Temperature Sensor` This preserves the full entity name including Unicode characters, spaces, and special characters. Since the name is what the user configured and is unique within the device, this provides true uniqueness without collisions. The "Device Name" rule in Home Assistant's requirements exists because device names can change or be duplicated across devices. For ESPHome entity names within a single device (identified by MAC), they ARE stable unique identifiers explicitly chosen by the user in their configuration. """ # -- entity_type = _TYPE_TO_NAME[type(entity_info)] if version == 1: return f"{formatted_mac}-{entity_type}-{entity_info.object_id}" # Version 2: use name directly to avoid mangling/collisions return f"{formatted_mac}-{entity_type}-{entity_info.name}" def message_types_to_names(msg_types: Iterable[type[message.Message]]) -> str: return ", ".join(t.__name__ for t in msg_types) __all__ = ( "COMPONENT_TYPE_TO_INFO", "APIIntEnum", "APIModelBase", "APIVersion", "AlarmControlPanelCommand", "AlarmControlPanelEntityFeature", "AlarmControlPanelEntityState", "AlarmControlPanelInfo", "AlarmControlPanelState", "AreaInfo", "BinarySensorInfo", "BinarySensorState", "BluetoothConnectionsFree", "BluetoothDeviceClearCache", "BluetoothDeviceConnection", "BluetoothDevicePairing", "BluetoothDeviceRequestType", "BluetoothDeviceUnpairing", "BluetoothGATTCharacteristic", "BluetoothGATTDescriptor", "BluetoothGATTError", "BluetoothGATTRead", "BluetoothGATTService", "BluetoothGATTServices", "BluetoothLEAdvertisement", "BluetoothProxyFeature", "BluetoothProxySubscriptionFlag", "BluetoothScannerMode", "BluetoothScannerState", "BluetoothScannerStateResponse", "ButtonInfo", "CameraInfo", "CameraState", "ClimateAction", "ClimateFanMode", "ClimateFeature", "ClimateInfo", "ClimateMode", "ClimatePreset", "ClimateState", "ClimateSwingMode", "ColorMode", "CommandProtoMessage", "CoverInfo", "CoverOperation", "CoverState", "DateInfo", "DateState", "DateTimeInfo", "DateTimeState", "DeviceInfo", "ESPHomeBluetoothGATTServices", "EntityCategory", "EntityInfo", "EntityState", "Event", "EventInfo", "ExecuteServiceResponse", "FanDirection", "FanInfo", "FanSpeed", "FanState", "HomeassistantActionResponse", "HomeassistantServiceCall", "InfraredCapability", "InfraredInfo", "InfraredRFReceiveEvent", "LastResetType", "LegacyCoverCommand", "LegacyCoverState", "LightColorCapability", "LightInfo", "LightState", "LockCommand", "LockEntityState", "LockInfo", "LockState", "LogLevel", "MediaPlayerCommand", "MediaPlayerEntityFeature", "MediaPlayerEntityState", "MediaPlayerFormatPurpose", "MediaPlayerInfo", "MediaPlayerState", "MediaPlayerSupportedFormat", "NoiseEncryptionSetKeyRequest", "NoiseEncryptionSetKeyResponse", "NumberInfo", "NumberMode", "NumberState", "RadioFrequencyCapability", "RadioFrequencyInfo", "RadioFrequencyModulation", "SelectInfo", "SelectState", "SensorInfo", "SensorState", "SensorStateClass", "SerialProxyDataReceived", "SerialProxyInfo", "SerialProxyModemPins", "SerialProxyParity", "SerialProxyPortType", "SerialProxyRequestResponse", "SerialProxyRequestType", "SerialProxyStatus", "SirenInfo", "SirenState", "SubDeviceInfo", "SupportsResponseType", "SwitchInfo", "SwitchState", "TemperatureUnit", "TextInfo", "TextMode", "TextSensorInfo", "TextSensorState", "TextState", "TimeInfo", "TimeState", "UpdateCommand", "UpdateInfo", "UpdateState", "UserService", "UserServiceArg", "UserServiceArgType", "ValveInfo", "ValveOperation", "ValveState", "VoiceAssistantAnnounceFinished", "VoiceAssistantAudioData", "VoiceAssistantAudioSettings", "VoiceAssistantCommand", "VoiceAssistantCommandFlag", "VoiceAssistantConfigurationRequest", "VoiceAssistantConfigurationResponse", "VoiceAssistantEventType", "VoiceAssistantExternalWakeWord", "VoiceAssistantFeature", "VoiceAssistantSetConfiguration", "VoiceAssistantSubscriptionFlag", "VoiceAssistantTimerEventType", "VoiceAssistantWakeWord", "WaterHeaterCommandField", "WaterHeaterFeature", "WaterHeaterInfo", "WaterHeaterMode", "WaterHeaterState", "WaterHeaterStateFlag", "ZWaveProxyFeature", "ZWaveProxyFrame", "ZWaveProxyRequest", "ZWaveProxyRequestType", "build_unique_id", )