Coverage for custom_components / supernotify / transports / pushover.py: 95%

110 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-11 22:18 +0000

1"""Pushover transport for SuperNotify. 

2 

3Sends push notifications via Pushover (https://pushover.net). 

4Requires the official HA Pushover integration configured in configuration.yaml: 

5 

6 notify: 

7 - name: pushover_home 

8 platform: pushover 

9 api_key: YOUR_PUSHOVER_API_KEY 

10 user_key: YOUR_PUSHOVER_USER_KEY 

11 

12The notify service name (e.g. notify.pushover_home) MUST be specified as 

13`action:` in delivery.yaml — there is no default, since the name depends on 

14the user's configuration.yaml entry. 

15 

16Priority mapping (SuperNotify -> Pushover integer): 

17 critical -> 2 (emergency: requires retry+expire, repeats until acknowledged) 

18 high -> 1 (high: bypasses user's quiet hours) 

19 medium -> 0 (normal: standard sound and vibration) 

20 low -> -1 (low: no sound and no vibration) 

21 minimum -> -2 (silent: only iOS badge, no visible notification) 

22 

23Note on emergency (priority=2): Pushover REQUIRES the `retry` and `expire` 

24parameters. If not provided, SuperNotify supplies sensible defaults 

25(retry=60s, expire=3600s) and logs them. 

26 

27Supported data keys (all optional unless noted): 

28 pushover_priority int (-2..2) Override priority; out-of-range -> auto-mapping. 

29 pushover_sound str Notification sound: "pushover", "bike", "siren", 

30 "vibrate", "none", "alien", "echo", etc. 

31 See https://pushover.net/api#sounds 

32 pushover_url str Supplementary URL attached to the notification. 

33 pushover_url_title str Title for the URL (max 100 chars). 

34 pushover_retry int Seconds between retries (min 30, default 60). 

35 Emergency only (priority=2). 

36 pushover_expire int Total seconds to keep retrying (max 10800, 

37 default 3600). Emergency only. 

38 pushover_callback str Public URL for emergency acknowledgment webhook 

39 (HA webhook endpoint). 

40 pushover_html bool Enable HTML formatting in the message 

41 (links, bold, italic). Default: false. 

42 pushover_ttl int Seconds before automatic deletion of the 

43 notification from the device. 

44 pushover_device str Send to a specific device (device name as 

45 configured in Pushover, e.g. "iphone"). 

46 Default: all devices on the account. 

47 pushover_attach_image bool Grab camera snapshot (uses 

48 media.camera_entity_id from the SuperNotify 

49 call) and attach it to the notification. 

50 Requires TransportFeature.SNAPSHOT_IMAGE. 

51""" 

52 

53from __future__ import annotations 

54 

55import logging 

56from typing import TYPE_CHECKING, Any 

57 

58from homeassistant.components.notify.const import ATTR_DATA 

59 

60from custom_components.supernotify.common import boolify 

61from custom_components.supernotify.const import TRANSPORT_PUSHOVER 

62from custom_components.supernotify.model import ( 

63 DebugTrace, 

64 TargetRequired, 

65 TransportConfig, 

66 TransportFeature, 

67) 

68from custom_components.supernotify.transport import Transport 

69 

70if TYPE_CHECKING: 

71 from custom_components.supernotify.envelope import Envelope 

72 

73_LOGGER = logging.getLogger(__name__) 

74 

75# SuperNotify priority -> Pushover integer (-2..2) 

76_PRIORITY_MAP: dict[str, int] = { 

77 "critical": 2, # emergency - repeats until acknowledged, requires retry+expire 

78 "high": 1, # high - bypasses user quiet hours 

79 "medium": 0, # normal - standard sound and vibration 

80 "low": -1, # low - no sound and no vibration 

81 "minimum": -2, # silent - only iOS badge, no visible notification 

82} 

83 

84_EMERGENCY_PRIORITY = 2 

85_EMERGENCY_RETRY_MIN = 30 # seconds (Pushover API limit) 

86_EMERGENCY_EXPIRE_MAX = 10800 # seconds (Pushover API limit = 3 hours) 

87_EMERGENCY_RETRY_DEFAULT = 60 # sensible default when not specified 

88_EMERGENCY_EXPIRE_DEFAULT = 3600 # sensible default when not specified (1 hour) 

89 

90 

91class PushoverTransport(Transport): 

92 """Notify via Pushover push notification service.""" 

93 

94 name = TRANSPORT_PUSHOVER 

95 

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

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

98 

99 @property 

100 def supported_features(self) -> TransportFeature: 

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

102 

103 @property 

104 def default_config(self) -> TransportConfig: 

105 config = TransportConfig() 

106 config.delivery_defaults.target_required = TargetRequired.NEVER 

107 # No default action — user MUST specify action: notify.<name> in delivery.yaml 

108 return config 

109 

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

111 if action and action.startswith("notify."): 

112 return True 

113 _LOGGER.warning( 

114 "SUPERNOTIFY pushover: action must be a notify.* service (e.g. notify.pushover_home), got: %r", 

115 action, 

116 ) 

117 return False 

118 

119 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: # noqa: ARG002 

120 _LOGGER.debug("SUPERNOTIFY pushover %s", envelope.message) 

121 

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

123 

124 # --- Pop pushover_* keys (must not be forwarded to the service) --- 

125 priority_ovr_raw = raw_data.pop("pushover_priority", None) 

126 sound = raw_data.pop("pushover_sound", None) 

127 url = raw_data.pop("pushover_url", None) 

128 url_title = raw_data.pop("pushover_url_title", None) 

129 retry_raw = raw_data.pop("pushover_retry", None) 

130 expire_raw = raw_data.pop("pushover_expire", None) 

131 callback = raw_data.pop("pushover_callback", None) 

132 html_flag = boolify(raw_data.pop("pushover_html", False), default=False) 

133 ttl_raw = raw_data.pop("pushover_ttl", None) 

134 device = raw_data.pop("pushover_device", None) 

135 attach_image = boolify(raw_data.pop("pushover_attach_image", False), default=False) 

136 

137 # --- Priority: validate override or use auto-mapping --- 

138 priority_ovr: int | None = None 

139 if priority_ovr_raw is not None: 

140 try: 

141 priority_ovr = int(priority_ovr_raw) 

142 if not -2 <= priority_ovr <= 2: 

143 _LOGGER.warning( 

144 "SUPERNOTIFY pushover: pushover_priority %d out of range -2..2, falling back to auto mapping", 

145 priority_ovr, 

146 ) 

147 priority_ovr = None 

148 except (TypeError, ValueError): # py3.13 compat 

149 _LOGGER.warning( 

150 "SUPERNOTIFY pushover: invalid pushover_priority %r, falling back to auto mapping", 

151 priority_ovr_raw, 

152 ) 

153 priority_ovr = None 

154 

155 pushover_priority: int = ( 

156 priority_ovr if priority_ovr is not None else _PRIORITY_MAP.get(envelope.priority or "medium", 0) 

157 ) 

158 

159 # --- Base action data (includes message and title) --- 

160 action_data = envelope.core_action_data() 

161 

162 # --- Pushover-specific data payload --- 

163 push_data: dict[str, Any] = {"priority": pushover_priority} 

164 

165 # Emergency (priority=2): Pushover REQUIRES retry and expire 

166 if pushover_priority == _EMERGENCY_PRIORITY: 

167 # retry: robust parse (YAML string or int) -> fallback default on error 

168 if retry_raw is None: 

169 retry_val: int = _EMERGENCY_RETRY_DEFAULT 

170 else: 

171 try: 

172 retry_val = int(retry_raw) 

173 except (TypeError, ValueError): 

174 _LOGGER.warning( 

175 "SUPERNOTIFY pushover: invalid pushover_retry %r, using default %ds", 

176 retry_raw, 

177 _EMERGENCY_RETRY_DEFAULT, 

178 ) 

179 retry_val = _EMERGENCY_RETRY_DEFAULT 

180 

181 # expire: same robust pattern 

182 if expire_raw is None: 

183 expire_val: int = _EMERGENCY_EXPIRE_DEFAULT 

184 else: 

185 try: 

186 expire_val = int(expire_raw) 

187 except (TypeError, ValueError): 

188 _LOGGER.warning( 

189 "SUPERNOTIFY pushover: invalid pushover_expire %r, using default %ds", 

190 expire_raw, 

191 _EMERGENCY_EXPIRE_DEFAULT, 

192 ) 

193 expire_val = _EMERGENCY_EXPIRE_DEFAULT 

194 

195 if retry_val < _EMERGENCY_RETRY_MIN: 

196 _LOGGER.warning( 

197 "SUPERNOTIFY pushover: emergency retry %ds < minimum %ds, clamping", 

198 retry_val, 

199 _EMERGENCY_RETRY_MIN, 

200 ) 

201 retry_val = _EMERGENCY_RETRY_MIN 

202 

203 if expire_val > _EMERGENCY_EXPIRE_MAX: 

204 _LOGGER.warning( 

205 "SUPERNOTIFY pushover: emergency expire %ds > maximum %ds, clamping", 

206 expire_val, 

207 _EMERGENCY_EXPIRE_MAX, 

208 ) 

209 expire_val = _EMERGENCY_EXPIRE_MAX 

210 

211 push_data["retry"] = retry_val 

212 push_data["expire"] = expire_val 

213 

214 if callback: 

215 push_data["callback"] = callback 

216 

217 _LOGGER.debug( 

218 "SUPERNOTIFY pushover: emergency mode - retry=%ds expire=%ds", 

219 retry_val, 

220 expire_val, 

221 ) 

222 

223 # Optional fields - added only when present 

224 if sound: 

225 push_data["sound"] = sound 

226 if url: 

227 push_data["url"] = url 

228 if url_title: 

229 push_data["url_title"] = url_title 

230 if html_flag: 

231 push_data["html"] = 1 

232 if ttl_raw is not None: 

233 try: 

234 push_data["ttl"] = int(ttl_raw) 

235 except (TypeError, ValueError): 

236 _LOGGER.warning("SUPERNOTIFY pushover: invalid pushover_ttl %r, ignored", ttl_raw) 

237 if device: 

238 push_data["device"] = device 

239 

240 # --- Camera image attachment via envelope.grab_image() (v1.14.0+) --- 

241 if attach_image: 

242 try: 

243 image_path = await envelope.grab_image() 

244 if image_path: 

245 push_data["attachment"] = str(image_path) 

246 _LOGGER.debug("SUPERNOTIFY pushover: attaching image %s", image_path) 

247 except Exception as e: 

248 _LOGGER.warning("SUPERNOTIFY pushover: failed to grab image: %s", e) 

249 

250 action_data[ATTR_DATA] = push_data 

251 

252 # Remaining raw_data is NOT forwarded - Pushover HA service schema is fixed 

253 return await self.call_action(envelope, action_data=action_data)