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