From 53b919fd15ff319341865223446016bb49765291 Mon Sep 17 00:00:00 2001 From: Torsten Date: Thu, 11 Jun 2026 11:35:08 +0000 Subject: [PATCH] Dateien nach "/" hochladen --- services.yaml | 126 ++++++++++++++++++++++++++++++ store.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 services.yaml create mode 100644 store.py diff --git a/services.yaml b/services.yaml new file mode 100644 index 0000000..20a82a9 --- /dev/null +++ b/services.yaml @@ -0,0 +1,126 @@ +add_notification: + name: Add notification + description: Add a notification to the Notification Store. + fields: + title: + name: Title + description: Notification title. + required: true + example: Washing machine finished + selector: + text: + message: + name: Message + description: Notification message. + required: true + example: The washing machine program is done. + selector: + text: + multiline: true + level: + name: Level + description: Notification severity. + default: info + selector: + select: + options: + - info + - success + - warning + - critical + tag: + name: Tag + description: Optional grouping tag. + selector: + text: + source: + name: Source + description: Optional source label. + selector: + text: + icon: + name: Icon + description: Optional Material Design icon, for example mdi:washing-machine. + selector: + icon: + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: + +mark_all_read: + name: Mark all read + description: Mark all unread notifications as read. + fields: + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: + +mark_one_read: + name: Mark one read + description: Mark one notification as read by ID. + fields: + notification_id: + name: Notification ID + description: ID of the notification to mark as read. + required: true + selector: + text: + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: + +mark_index_read: + name: Mark index read + description: Mark one unread notification as read by its unread list index. + fields: + index: + name: Index + description: Zero-based index in the unread notification list. + required: true + selector: + number: + min: 0 + max: 1000 + step: 1 + mode: box + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: + +clear_read: + name: Clear read + description: Delete all read notifications. + fields: + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: + +clear_all: + name: Clear all + description: Delete all notifications. + fields: + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: + +reload: + name: Reload + description: Reload notification data from disk. + fields: + entry_id: + name: Entry ID + description: Optional config entry ID when multiple stores exist. + selector: + text: diff --git a/store.py b/store.py new file mode 100644 index 0000000..b0cf15d --- /dev/null +++ b/store.py @@ -0,0 +1,208 @@ +"""Persistent JSON store for Notification Store.""" + +from __future__ import annotations + +import json +import os +import uuid +from copy import deepcopy +from datetime import datetime +from pathlib import Path +from typing import Any + +from homeassistant.core import HomeAssistant + +from .const import DEFAULT_STORE_PATH, LEVEL_INFO, LEVELS + + +def _now_iso() -> str: + """Return a local ISO timestamp without microseconds.""" + return datetime.now().isoformat(timespec="seconds") + + +def _empty_data() -> dict[str, list[dict[str, Any]]]: + """Return an empty store structure.""" + return {"notifications": []} + + +def _normalize_notification(item: Any) -> dict[str, Any] | None: + """Normalize a notification item from disk.""" + if not isinstance(item, dict): + return None + + notification = dict(item) + notification.setdefault("id", str(uuid.uuid4())) + notification.setdefault("title", "") + notification.setdefault("message", "") + notification.setdefault("level", LEVEL_INFO) + notification.setdefault("tag", "") + notification.setdefault("source", "") + notification.setdefault("icon", "") + notification.setdefault("status", "unread") + notification.setdefault("created", _now_iso()) + + if notification.get("level") not in LEVELS: + notification["level"] = LEVEL_INFO + + if notification.get("status") not in ["unread", "read"]: + notification["status"] = "unread" + + return notification + + +def _normalize_data(data: Any) -> dict[str, list[dict[str, Any]]]: + """Normalize store data from disk.""" + if not isinstance(data, dict): + return _empty_data() + + notifications = data.get("notifications", []) + if not isinstance(notifications, list): + notifications = [] + + normalized = [] + for item in notifications: + notification = _normalize_notification(item) + if notification is not None: + normalized.append(notification) + + return {"notifications": normalized} + + +class NotificationStore: + """Read and write notification data from a JSON file.""" + + def __init__(self, hass: HomeAssistant, store_path: str | None = None) -> None: + """Initialize the store.""" + self.hass = hass + self.path = self._resolve_path(store_path or DEFAULT_STORE_PATH) + + def _resolve_path(self, store_path: str) -> str: + """Resolve a relative Home Assistant config path or absolute path.""" + path = Path(store_path).expanduser() + if path.is_absolute(): + return str(path) + return self.hass.config.path(str(path)) + + def _load_sync(self) -> dict[str, list[dict[str, Any]]]: + """Load the store synchronously.""" + if not os.path.exists(self.path): + return _empty_data() + + try: + with open(self.path, "r", encoding="utf-8") as file: + return _normalize_data(json.load(file)) + except Exception: + return _empty_data() + + def _save_sync(self, data: dict[str, Any]) -> None: + """Save the store synchronously.""" + os.makedirs(os.path.dirname(self.path), exist_ok=True) + normalized = _normalize_data(data) + with open(self.path, "w", encoding="utf-8") as file: + json.dump(normalized, file, ensure_ascii=False, indent=2) + + async def async_load(self) -> dict[str, list[dict[str, Any]]]: + """Load the store asynchronously.""" + data = await self.hass.async_add_executor_job(self._load_sync) + return deepcopy(data) + + async def async_save(self, data: dict[str, Any]) -> None: + """Save the store asynchronously.""" + await self.hass.async_add_executor_job(self._save_sync, data) + + async def async_add_notification( + self, + title: str, + message: str, + level: str = LEVEL_INFO, + tag: str = "", + source: str = "", + icon: str = "", + ) -> dict[str, Any]: + """Add a notification and return it.""" + data = await self.async_load() + notification = { + "id": str(uuid.uuid4()), + "title": title, + "message": message, + "level": level if level in LEVELS else LEVEL_INFO, + "tag": tag, + "source": source, + "icon": icon, + "status": "unread", + "created": _now_iso(), + } + data.setdefault("notifications", []).insert(0, notification) + await self.async_save(data) + return notification + + async def async_mark_all_read(self) -> int: + """Mark all notifications as read and return changed count.""" + data = await self.async_load() + changed = 0 + for item in data.get("notifications", []): + if item.get("status", "unread") == "unread": + item["status"] = "read" + item["read_at"] = _now_iso() + changed += 1 + await self.async_save(data) + return changed + + async def async_mark_one_read(self, notification_id: str) -> bool: + """Mark one notification as read.""" + data = await self.async_load() + found = False + for item in data.get("notifications", []): + if item.get("id") == notification_id: + item["status"] = "read" + item["read_at"] = _now_iso() + found = True + break + await self.async_save(data) + return found + + async def async_mark_index_read(self, index: int) -> str | None: + """Mark one unread notification by unread index as read.""" + data = await self.async_load() + unread = [ + item + for item in data.get("notifications", []) + if item.get("status", "unread") == "unread" + ] + + if index < 0 or index >= len(unread): + return None + + notification_id = unread[index].get("id") + if not notification_id: + return None + + for item in data.get("notifications", []): + if item.get("id") == notification_id: + item["status"] = "read" + item["read_at"] = _now_iso() + break + + await self.async_save(data) + return notification_id + + async def async_clear_read(self) -> int: + """Delete read notifications and return removed count.""" + data = await self.async_load() + notifications = data.get("notifications", []) + kept = [ + item + for item in notifications + if item.get("status", "unread") == "unread" + ] + removed = len(notifications) - len(kept) + data["notifications"] = kept + await self.async_save(data) + return removed + + async def async_clear_all(self) -> int: + """Delete all notifications and return removed count.""" + data = await self.async_load() + removed = len(data.get("notifications", [])) + await self.async_save(_empty_data()) + return removed