Dateien nach "/" hochladen

This commit is contained in:
2026-06-11 11:35:08 +00:00
parent 57d8f5445f
commit 53b919fd15
2 changed files with 334 additions and 0 deletions

126
services.yaml Normal file
View File

@@ -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:

208
store.py Normal file
View File

@@ -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