Coverage for custom_components/supernotify/transports/gotify.py: 100%
72 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1"""Gotify transport for SuperNotify.
3Sends push notifications via Gotify (self-hosted, privacy-first push server).
4Requires the HACS custom integration 1RandomDev/homeassistant-gotify installed
5and configured in configuration.yaml. The notify service name (e.g. notify.gotify)
6depends on the user's HACS configuration - auto_configure() discovers it via the
7registered service's module, or it can be set manually as `action:` on a delivery.
9Prerequisites:
10 - Gotify server running and reachable
11 - HACS integration 1RandomDev/homeassistant-gotify installed
12 - Application token configured in configuration.yaml
13 - `action: notify.<name>` set on a delivery only if auto-discovery doesn't apply
15For snapshot camera / bigImageUrl:
16 - `media_web_path` must be configured (PLATFORM_SCHEMA) for grab_image to produce a URL.
17 - `external_url` must be configured in HA for the URL to be reachable outside the local network.
19Supported data: keys (all optional):
20 gotify_priority int (0-10) Override priority (0=silent ... 10=max).
21 Accepts string "7" -> cast to int.
22 Out-of-range values are clamped.
23 gotify_click str (URL) URL opened on tap of the notification.
24 gotify_image_url str (URL) Direct URL for bigImageUrl (expanded image).
25 Takes precedence over gotify_attach_image.
26 gotify_attach_image bool Grab image via shared pipeline and use as bigImageUrl.
27 Used only when no snapshot_url is already in media.
28 Requires media_web_path configured and image saved within it.
29 gotify_markdown bool Enable Markdown rendering (text/markdown).
30 Accepts "true"/"false" YAML strings safely.
31 gotify_intent_url str (URL) Android intent URL on message receive.
32 Requires "Intent Action Permission" in Gotify app.
34Priority mapping (SuperNotify -> Gotify integer):
35 critical -> 10 high -> 7 medium -> 5 low -> 2 minimum -> 0
36"""
38from __future__ import annotations
40import logging
41from typing import TYPE_CHECKING, Any, ClassVar
43from homeassistant.components.notify.const import ATTR_DATA
45from custom_components.supernotify.common import boolify
46from custom_components.supernotify.const import TRANSPORT_GOTIFY
47from custom_components.supernotify.model import (
48 DebugTrace,
49 TransportConfig,
50 TransportFeature,
51)
52from custom_components.supernotify.options import MEDIA_OPTIONS, DeliveryOption
53from custom_components.supernotify.transport import Transport
55if TYPE_CHECKING:
56 from custom_components.supernotify.envelope import Envelope
58# Gotify's HACS notify platform module (no longer bundled with HA core)
59HA_GOTIFY_MODULE = "custom_components.gotify.notify"
61_LOGGER = logging.getLogger(__name__)
63_PRIORITY_MAP: dict[str, int] = {
64 "critical": 10,
65 "high": 7,
66 "medium": 5,
67 "low": 2,
68 "minimum": 0,
69}
72def _build_extras(
73 click_url: str | None,
74 image_url: str | None,
75 markdown: bool,
76 intent_url: str | None,
77) -> dict | None:
78 """Build Gotify extras dict. Returns None if no extras are needed."""
79 extras: dict = {}
81 client_notification: dict = {}
82 if click_url:
83 client_notification["click"] = {"url": click_url}
84 if image_url:
85 client_notification["bigImageUrl"] = image_url
86 if client_notification:
87 extras["client::notification"] = client_notification
89 if markdown:
90 extras["client::display"] = {"contentType": "text/markdown"}
92 if intent_url:
93 extras["android::action"] = {"onReceive": {"intentUrl": intent_url}}
95 return extras or None
98class GotifyTransport(Transport):
99 """Notify via Gotify self-hosted push notification server."""
101 name = TRANSPORT_GOTIFY
102 integration_domain = "gotify"
103 action_configured = True
104 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
106 def __init__(self, *args: Any, **kwargs: Any) -> None:
107 super().__init__(*args, **kwargs)
109 @property
110 def supported_features(self) -> TransportFeature:
111 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
113 @property
114 def default_config(self) -> TransportConfig:
115 config = super().default_config
116 config.delivery_defaults.action = self.hass_api.find_service("notify", HA_GOTIFY_MODULE)
117 return config
119 def validate_action(self, action: str | None) -> bool:
120 return self.validate_notify_action(action)
122 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
123 _LOGGER.debug("SUPERNOTIFY gotify %s", envelope.message)
125 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
127 # --- Extract gotify_* keys (must not reach the notify service) ---
128 priority_ovr_raw = raw_data.pop("gotify_priority", None)
129 click_url = raw_data.pop("gotify_click", None) or envelope.notification_uri(absolute=True)
130 image_url: str | None = raw_data.pop("gotify_image_url", None)
131 attach_image = boolify(raw_data.pop("gotify_attach_image", False), default=False)
132 markdown = boolify(raw_data.pop("gotify_markdown", False), default=False)
133 intent_url = raw_data.pop("gotify_intent_url", None)
135 # --- Priority: validate override or use auto-mapping ---
136 priority_ovr: int | None = None
137 if priority_ovr_raw is not None:
138 try:
139 priority_ovr = int(priority_ovr_raw)
140 if not 0 <= priority_ovr <= 10:
141 _LOGGER.warning(
142 "SUPERNOTIFY gotify: gotify_priority %d out of range 0-10, clamping",
143 priority_ovr,
144 )
145 priority_ovr = max(0, min(10, priority_ovr))
146 except (TypeError, ValueError) as e: # py3.13 compat
147 _LOGGER.warning("SUPERNOTIFY gotify: invalid gotify_priority %r, using auto mapping: %s", priority_ovr_raw, e)
148 priority_ovr = None
150 gotify_priority: int = priority_ovr if priority_ovr is not None else _PRIORITY_MAP.get(envelope.priority or "medium", 5)
152 # --- Base action data ---
153 action_data = envelope.core_action_data()
155 # --- Resolve image_url (bigImageUrl): explicit > snapshot_url > grab_image ---
156 if not image_url:
157 image_url = await self.image_url(envelope, grab=attach_image)
159 # --- Build nested payload_data ---
160 payload_data: dict[str, Any] = {"priority": gotify_priority}
162 extras = _build_extras(click_url, image_url, markdown, intent_url)
163 if extras:
164 payload_data["extras"] = extras
166 action_data[ATTR_DATA] = payload_data
168 # the rest of raw_data isn't passed on - the HACS integration only reads priority and extras
169 return await self.call_action(envelope, action_data=action_data)