"""ZCL helpers.""" from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from .foundation import ZCLAttributeDef if TYPE_CHECKING: from . import Cluster CacheKey = tuple[int, int | None] # attribute id, manufacturer code @dataclass(kw_only=True, frozen=True) class CacheItem: value: Any last_updated: datetime class UnsupportedAttribute(Exception): """Exception for unsupported attributes.""" def __init__(self, attr_def: ZCLAttributeDef) -> None: super().__init__(f"Attribute {attr_def} is unsupported") class AttributeCache: def __init__(self, cluster) -> None: self._cluster = cluster self._cache: dict[CacheKey, CacheItem] = {} self._unsupported: set[CacheKey] = set() # FIXME: Legacy cache for unknown attributes from quirks that use the attribute # cache as generic data storage. Keyed by attr_id only. self._legacy_cache: dict[int, CacheItem] = {} def _cache_key(self, attr_def: ZCLAttributeDef) -> CacheKey: """Create a cache key using the effective manufacturer code.""" return ( attr_def.id, self._cluster._get_effective_manufacturer_code(attr_def), ) def clear(self) -> None: self._cache.clear() self._unsupported.clear() self._legacy_cache.clear() def remove(self, attr_def: ZCLAttributeDef) -> None: key = self._cache_key(attr_def) self._cache.pop(key, None) self._unsupported.discard(key) def _raise_if_unsupported(self, attr_def: ZCLAttributeDef) -> None: if self._cache_key(attr_def) in self._unsupported: raise UnsupportedAttribute(attr_def) def remove_unsupported(self, attr_def: ZCLAttributeDef) -> None: self._unsupported.discard(self._cache_key(attr_def)) def mark_unsupported(self, attr_def: ZCLAttributeDef) -> None: self._unsupported.add(self._cache_key(attr_def)) def is_unsupported(self, attr_def: ZCLAttributeDef) -> bool: return self._cache_key(attr_def) in self._unsupported def get_value(self, attr_def: ZCLAttributeDef) -> Any: self._raise_if_unsupported(attr_def) return self._cache[self._cache_key(attr_def)].value def get_last_updated(self, attr_def: ZCLAttributeDef) -> datetime: self._raise_if_unsupported(attr_def) return self._cache[self._cache_key(attr_def)].last_updated def set_value( self, attr_def: ZCLAttributeDef, value: Any, *, last_updated: datetime | None = None, ) -> None: self.remove_unsupported(attr_def) self._cache[self._cache_key(attr_def)] = CacheItem( value=value, last_updated=datetime.now(UTC) if last_updated is None else last_updated, ) def set_legacy_value( self, attr_id: int, value: Any, *, last_updated: datetime | None = None ) -> None: """Store a value in the legacy cache for unknown attributes.""" self._legacy_cache[attr_id] = CacheItem( value=value, last_updated=datetime.now(UTC) if last_updated is None else last_updated, ) def get(self, key: int, default: Any | None = None) -> Any: try: return self[key] except (KeyError, UnsupportedAttribute): return default def __getitem__(self, key: int) -> Any: try: return self.get_value(self._cluster.find_attribute(key)) except KeyError: # Fall back to legacy cache for unknown attributes if key in self._legacy_cache: return self._legacy_cache[key].value raise def __setitem__(self, key: int, value: Any) -> None: attr_def = self._cluster.find_attribute(key) self.set_value(attr_def, value) def update(self, updates: dict[int, Any]) -> None: for attr_id, value in updates.items(): self[attr_id] = value def __contains__(self, key: int) -> bool: try: self[key] except UnsupportedAttribute: return False except KeyError: return False else: return True def clone(self, cluster: Cluster) -> AttributeCache: """Create a copy of this cache for a new cluster.""" new_cache = AttributeCache(cluster) new_cache._cache = self._cache.copy() new_cache._unsupported = self._unsupported.copy() new_cache._legacy_cache = self._legacy_cache.copy() return new_cache @dataclass(frozen=True) class ReportingConfig: """Reporting config for a ZCL attribute.""" min_interval: int max_interval: int reportable_change: int