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

1"""Gotify transport for SuperNotify. 

2 

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. 

8 

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 

14 

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. 

18 

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. 

33 

34Priority mapping (SuperNotify -> Gotify integer): 

35 critical -> 10 high -> 7 medium -> 5 low -> 2 minimum -> 0 

36""" 

37 

38from __future__ import annotations 

39 

40import logging 

41from typing import TYPE_CHECKING, Any, ClassVar 

42 

43from homeassistant.components.notify.const import ATTR_DATA 

44 

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 

54 

55if TYPE_CHECKING: 

56 from custom_components.supernotify.envelope import Envelope 

57 

58# Gotify's HACS notify platform module (no longer bundled with HA core) 

59HA_GOTIFY_MODULE = "custom_components.gotify.notify" 

60 

61_LOGGER = logging.getLogger(__name__) 

62 

63_PRIORITY_MAP: dict[str, int] = { 

64 "critical": 10, 

65 "high": 7, 

66 "medium": 5, 

67 "low": 2, 

68 "minimum": 0, 

69} 

70 

71 

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 = {} 

80 

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 

88 

89 if markdown: 

90 extras["client::display"] = {"contentType": "text/markdown"} 

91 

92 if intent_url: 

93 extras["android::action"] = {"onReceive": {"intentUrl": intent_url}} 

94 

95 return extras or None 

96 

97 

98class GotifyTransport(Transport): 

99 """Notify via Gotify self-hosted push notification server.""" 

100 

101 name = TRANSPORT_GOTIFY 

102 integration_domain = "gotify" 

103 action_configured = True 

104 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS] 

105 

106 def __init__(self, *args: Any, **kwargs: Any) -> None: 

107 super().__init__(*args, **kwargs) 

108 

109 @property 

110 def supported_features(self) -> TransportFeature: 

111 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE 

112 

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 

118 

119 def validate_action(self, action: str | None) -> bool: 

120 return self.validate_notify_action(action) 

121 

122 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: 

123 _LOGGER.debug("SUPERNOTIFY gotify %s", envelope.message) 

124 

125 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {} 

126 

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) 

134 

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 

149 

150 gotify_priority: int = priority_ovr if priority_ovr is not None else _PRIORITY_MAP.get(envelope.priority or "medium", 5) 

151 

152 # --- Base action data --- 

153 action_data = envelope.core_action_data() 

154 

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) 

158 

159 # --- Build nested payload_data --- 

160 payload_data: dict[str, Any] = {"priority": gotify_priority} 

161 

162 extras = _build_extras(click_url, image_url, markdown, intent_url) 

163 if extras: 

164 payload_data["extras"] = extras 

165 

166 action_data[ATTR_DATA] = payload_data 

167 

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)