Dateien nach "/" hochladen
This commit is contained in:
126
services.yaml
Normal file
126
services.yaml
Normal 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
208
store.py
Normal 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
|
||||||
Reference in New Issue
Block a user