diff --git a/custom_components/spook/ectoplasms/spook/__init__.py b/custom_components/spook/ectoplasms/spook/__init__.py index f673d532..310fb833 100644 --- a/custom_components/spook/ectoplasms/spook/__init__.py +++ b/custom_components/spook/ectoplasms/spook/__init__.py @@ -1 +1,17 @@ """Spook - Not your homie.""" + + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .storage import STORAGE_KEY, SpookKeyValueStore + + +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up the Spook ectoplasm.""" + # Initialize the Spook key/value store. + store = SpookKeyValueStore(hass) + await store.async_initialize() + hass.data[STORAGE_KEY] = store + + return True diff --git a/custom_components/spook/ectoplasms/spook/services/storage_delete.py b/custom_components/spook/ectoplasms/spook/services/storage_delete.py new file mode 100644 index 00000000..65069c34 --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/services/storage_delete.py @@ -0,0 +1,30 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from ....const import DOMAIN +from ....services import AbstractSpookAdminService +from .. import STORAGE_KEY, SpookKeyValueStore + +if TYPE_CHECKING: + from homeassistant.core import ServiceCall + + +class SpookService(AbstractSpookAdminService): + """Service to delete a value from the Spook key/value storage.""" + + domain = DOMAIN + service = "storage_delete" + schema = { + vol.Required("key"): cv.string, + } + + async def async_handle_service(self, call: ServiceCall) -> None: + """Handle the service call.""" + store: SpookKeyValueStore = self.hass.data[STORAGE_KEY] + store.async_delete(call.data["key"]) diff --git a/custom_components/spook/ectoplasms/spook/services/storage_dump.py b/custom_components/spook/ectoplasms/spook/services/storage_dump.py new file mode 100644 index 00000000..f758a860 --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/services/storage_dump.py @@ -0,0 +1,26 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.core import SupportsResponse + +from ....const import DOMAIN +from ....services import AbstractSpookService +from .. import STORAGE_KEY, SpookKeyValueStore + +if TYPE_CHECKING: + from homeassistant.core import ServiceCall, ServiceResponse + + +class SpookService(AbstractSpookService): + """Service dump all values in the Spook key/value storage.""" + + domain = DOMAIN + service = "storage_dump" + supports_response = SupportsResponse.ONLY + + async def async_handle_service(self, _: ServiceCall) -> ServiceResponse: + """Handle the service call.""" + store: SpookKeyValueStore = self.hass.data[STORAGE_KEY] + return store.async_dump() diff --git a/custom_components/spook/ectoplasms/spook/services/storage_flush.py b/custom_components/spook/ectoplasms/spook/services/storage_flush.py new file mode 100644 index 00000000..cfb5079f --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/services/storage_flush.py @@ -0,0 +1,23 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ....const import DOMAIN +from ....services import AbstractSpookAdminService +from .. import STORAGE_KEY, SpookKeyValueStore + +if TYPE_CHECKING: + from homeassistant.core import ServiceCall + + +class SpookService(AbstractSpookAdminService): + """Service to flush all stored key/values from the Spook key/value storage.""" + + domain = DOMAIN + service = "storage_flush" + + async def async_handle_service(self, _: ServiceCall) -> None: + """Handle the service call.""" + store: SpookKeyValueStore = self.hass.data[STORAGE_KEY] + store.async_flush() diff --git a/custom_components/spook/ectoplasms/spook/services/storage_keys.py b/custom_components/spook/ectoplasms/spook/services/storage_keys.py new file mode 100644 index 00000000..a99d9c0d --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/services/storage_keys.py @@ -0,0 +1,26 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.core import SupportsResponse + +from ....const import DOMAIN +from ....services import AbstractSpookService +from .. import STORAGE_KEY, SpookKeyValueStore + +if TYPE_CHECKING: + from homeassistant.core import ServiceCall, ServiceResponse + + +class SpookService(AbstractSpookService): + """Service to get all keys store in the Spook key/value storage.""" + + domain = DOMAIN + service = "storage_keys" + supports_response = SupportsResponse.ONLY + + async def async_handle_service(self, _: ServiceCall) -> ServiceResponse: + """Handle the service call.""" + store: SpookKeyValueStore = self.hass.data[STORAGE_KEY] + return {"keys": store.async_keys()} diff --git a/custom_components/spook/ectoplasms/spook/services/storage_retrieve.py b/custom_components/spook/ectoplasms/spook/services/storage_retrieve.py new file mode 100644 index 00000000..aa74ee87 --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/services/storage_retrieve.py @@ -0,0 +1,38 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.core import SupportsResponse +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from ....const import DOMAIN +from ....services import AbstractSpookService +from .. import STORAGE_KEY, SpookKeyValueStore + +if TYPE_CHECKING: + from homeassistant.core import ServiceCall, ServiceResponse + + +class SpookService(AbstractSpookService): + """Service to retrieve an item form the key/value storage.""" + + domain = DOMAIN + service = "storage_retrieve" + schema = { + vol.Required("key"): cv.string, + } + supports_response = SupportsResponse.ONLY + + async def async_handle_service(self, call: ServiceCall) -> ServiceResponse: + """Handle the service call.""" + store: SpookKeyValueStore = self.hass.data[STORAGE_KEY] + key = call.data["key"] + try: + return store.async_retrieve(key) + except KeyError as err: + msg = f"Key '{key}' not found in Spook's key/value store." + raise ServiceValidationError(msg) from err diff --git a/custom_components/spook/ectoplasms/spook/services/storage_store.py b/custom_components/spook/ectoplasms/spook/services/storage_store.py new file mode 100644 index 00000000..f5e8176f --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/services/storage_store.py @@ -0,0 +1,36 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from ....const import DOMAIN +from ....services import AbstractSpookAdminService +from .. import STORAGE_KEY, SpookKeyValueStore + +if TYPE_CHECKING: + from homeassistant.core import ServiceCall + + +class SpookService(AbstractSpookAdminService): + """Service to store a value in the Spook key/value storage.""" + + domain = DOMAIN + service = "storage_store" + schema = { + vol.Required("key"): cv.string, + vol.Required("value"): cv.match_all, + vol.Optional("is_persistent"): cv.boolean, + vol.Optional("ttl"): cv.time_period, + } + + async def async_handle_service(self, call: ServiceCall) -> None: + """Handle the service call.""" + store: SpookKeyValueStore = self.hass.data[STORAGE_KEY] + data = call.data.copy() + if "ttl" in call.data: + data["ttl"] = call.data["ttl"].total_seconds() + store.async_store(**data) diff --git a/custom_components/spook/ectoplasms/spook/storage.py b/custom_components/spook/ectoplasms/spook/storage.py new file mode 100644 index 00000000..e6dad557 --- /dev/null +++ b/custom_components/spook/ectoplasms/spook/storage.py @@ -0,0 +1,121 @@ +"""Spook - Not your homie.""" +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any, TypedDict + +from homeassistant.core import callback +from homeassistant.helpers.storage import Store + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +_SENTINEL = object() + +STORAGE_KEY = "spook_key_value_store" +STORAGE_MAJOR_VERSION = 1 +STORAGE_MINOR_VERSION = 1 + + +class SpookKeyValueStoreItem(TypedDict): + """Spook key/value store item.""" + + key: str + last_modified: datetime | None + is_persistent: bool + ttl: int | None + value: Any + + +class SpookKeyValueStore: + """Key/Value storage for Spook.""" + + _persistent_storage: Store[str, SpookKeyValueStoreItem] + _store: dict[str, SpookKeyValueStoreItem] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the Spook key/value store.""" + self._hass = hass + self._persistent_storage = Store( + hass, + key=STORAGE_KEY, + version=STORAGE_MAJOR_VERSION, + minor_version=STORAGE_MINOR_VERSION, + atomic_writes=True, + private=True, + ) + + async def async_initialize(self) -> None: + """Initialize the Spook key/value store.""" + data = await self._persistent_storage.async_load() + self._store = data or {} + + @callback + def async_retrieve(self, key: str) -> SpookKeyValueStoreItem: + """Get a value from the store.""" + # If item is not found, raise an exception + # Also, if an item is found but is expired (last_modified + ttl is passed now), raise an exception + if ( + not (item := self._store.get(key)) + and item["ttl"] is not None + and item["last_modified"] + timedelta(seconds=item["ttl"]) + < datetime.now(tz=UTC) + ): + msg = f"Key {key} not found in Spook's key/value store" + raise KeyError(msg) + return item + + @callback + def _persistent_items(self) -> dict[str, SpookKeyValueStoreItem]: + """Get all persistent items from the store.""" + return {key: item for key, item in self._store.items() if item["persistent"]} + + @callback + def async_store( + self, + key: str, + value: Any, + is_persistent: bool | None = None, + ttl: int | None = _SENTINEL, + ) -> None: + """Set a value in the store.""" + if item := self._store.get(key): + if is_persistent is not None: + item["is_persistent"] = is_persistent + if ttl is not _SENTINEL: + item["ttl"] = ttl + item["value"] = value + item["last_modified"] = datetime.now(tz=UTC) + else: + item = SpookKeyValueStoreItem( + key=key, + value=value, + is_persistent=is_persistent or True, + ttl=None if _SENTINEL else ttl, + last_modified=datetime.now(tz=UTC), + ) + self._store[key] = item + self._persistent_storage.async_delay_save(self._persistent_items, 30) + + @callback + def async_flush(self) -> None: + """Flush the store.""" + self._store = {} + self._persistent_storage.async_delay_save(self._persistent_items, 30) + + @callback + def async_delete(self, key: str) -> None: + """Delete a value from the store.""" + if key in self._store: + del self._store[key] + self._persistent_storage.async_delay_save(self._store, 30) + + @callback + def async_dump(self) -> dict[str, SpookKeyValueStoreItem]: + """Dump the store.""" + return self._store + + @callback + def async_keys(self) -> list[str]: + """Get all keys in the store.""" + return list(self._store)