Coverage for custom_components/supernotify/transports/pushover.py: 95%
121 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
1"""Pushover transport for SuperNotify.
3Sends push notifications via Pushover (https://pushover.net).
4Requires the official HA Pushover integration configured in configuration.yaml:
6 notify:
7 - name: pushover_home
8 platform: pushover
9 api_key: YOUR_PUSHOVER_API_KEY
10 user_key: YOUR_PUSHOVER_USER_KEY
12The notify service name (e.g. notify.pushover_home) MUST be specified as
13`action:` on the delivery - there is no default, since the name depends on
14the user's configuration.yaml entry.
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)
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.
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"""
53from __future__ import annotations
55import logging
56from typing import TYPE_CHECKING, Any, ClassVar
58from homeassistant.components.notify.const import ATTR_DATA
59from homeassistant.helpers.typing import ConfigType
61from custom_components.supernotify.common import boolify
62from custom_components.supernotify.const import TRANSPORT_PUSHOVER
63from custom_components.supernotify.model import (
64 DebugTrace,
65 TargetRequired,
66 TransportConfig,
67 TransportFeature,
68)
69from custom_components.supernotify.options import MEDIA_OPTIONS, DeliveryOption
70from custom_components.supernotify.transport import Transport
72if TYPE_CHECKING:
73 from custom_components.supernotify.envelope import Envelope
74 from custom_components.supernotify.hass_api import HomeAssistantAPI
76_LOGGER = logging.getLogger(__name__)
78# SuperNotify priority -> Pushover integer (-2..2)
79_PRIORITY_MAP: dict[str, int] = {
80 "critical": 2, # emergency - repeats until acknowledged, requires retry+expire
81 "high": 1, # high - bypasses user quiet hours
82 "medium": 0, # normal - standard sound and vibration
83 "low": -1, # low - no sound and no vibration
84 "minimum": -2, # silent - only iOS badge, no visible notification
85}
87_EMERGENCY_PRIORITY = 2
88_EMERGENCY_RETRY_MIN = 30 # seconds (Pushover API limit)
89_EMERGENCY_EXPIRE_MAX = 10800 # seconds (Pushover API limit = 3 hours)
90_EMERGENCY_RETRY_DEFAULT = 60 # sensible default when not specified
91_EMERGENCY_EXPIRE_DEFAULT = 3600 # sensible default when not specified (1 hour)
94class PushoverTransport(Transport):
95 """Notify via Pushover push notification service."""
97 name = TRANSPORT_PUSHOVER
98 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
100 def __init__(self, *args: Any, **kwargs: Any) -> None:
101 super().__init__(*args, **kwargs)
103 @property
104 def supported_features(self) -> TransportFeature:
105 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
107 @property
108 def default_config(self) -> TransportConfig:
109 config = TransportConfig()
110 config.delivery_defaults.target_required = TargetRequired.NEVER
111 config.delivery_defaults.inclusion = self.inclusion_mode
112 config.delivery_defaults.action = self.hass_api.find_service("notify", "homeassistant.components.pushover.notify")
113 return config
115 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
116 # a manually configured delivery can set its own action: notify.<name> regardless
117 # of whether the service is discoverable here - is_viable() can't see delivery-level
118 # config, so it can't rule that out; DeliveryRegistry prunes this transport entirely
119 # once it's confirmed no delivery (explicit or auto) uses it
120 return True
122 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
123 if self.delivery_defaults.action:
124 return {self.name: {}}
125 return {}
127 def validate_action(self, action: str | None) -> bool:
128 if action and action.startswith("notify."):
129 return True
130 _LOGGER.warning(
131 "SUPERNOTIFY pushover: action must be a notify.* service (e.g. notify.pushover_home), got: %r",
132 action,
133 )
134 return False
136 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
137 _LOGGER.debug("SUPERNOTIFY pushover %s", envelope.message)
139 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
141 # --- Pop pushover_* keys (must not be forwarded to the service) ---
142 priority_ovr_raw = raw_data.pop("pushover_priority", None)
143 sound = raw_data.pop("pushover_sound", None)
144 url = raw_data.pop("pushover_url", None)
145 url_title = raw_data.pop("pushover_url_title", None)
146 retry_raw = raw_data.pop("pushover_retry", None)
147 expire_raw = raw_data.pop("pushover_expire", None)
148 callback = raw_data.pop("pushover_callback", None)
149 html_flag = boolify(raw_data.pop("pushover_html", False), default=False)
150 ttl_raw = raw_data.pop("pushover_ttl", None)
151 device = raw_data.pop("pushover_device", None)
152 attach_image = boolify(raw_data.pop("pushover_attach_image", False), default=False)
154 # --- Priority: validate override or use auto-mapping ---
155 priority_ovr: int | None = None
156 if priority_ovr_raw is not None:
157 try:
158 priority_ovr = int(priority_ovr_raw)
159 if not -2 <= priority_ovr <= 2:
160 _LOGGER.warning(
161 "SUPERNOTIFY pushover: pushover_priority %d out of range -2..2, falling back to auto mapping",
162 priority_ovr,
163 )
164 priority_ovr = None
165 except (TypeError, ValueError): # py3.13 compat
166 _LOGGER.warning(
167 "SUPERNOTIFY pushover: invalid pushover_priority %r, falling back to auto mapping",
168 priority_ovr_raw,
169 )
170 priority_ovr = None
172 pushover_priority: int = (
173 priority_ovr if priority_ovr is not None else _PRIORITY_MAP.get(envelope.priority or "medium", 0)
174 )
176 # --- Base action data (includes message and title) ---
177 action_data = envelope.core_action_data()
179 # --- Pushover-specific data payload ---
180 push_data: dict[str, Any] = {"priority": pushover_priority}
182 # Emergency (priority=2): Pushover REQUIRES retry and expire
183 if pushover_priority == _EMERGENCY_PRIORITY:
184 # retry: robust parse (YAML string or int) -> fallback default on error
185 if retry_raw is None:
186 retry_val: int = _EMERGENCY_RETRY_DEFAULT
187 else:
188 try:
189 retry_val = int(retry_raw)
190 except (TypeError, ValueError):
191 _LOGGER.warning(
192 "SUPERNOTIFY pushover: invalid pushover_retry %r, using default %ds",
193 retry_raw,
194 _EMERGENCY_RETRY_DEFAULT,
195 )
196 retry_val = _EMERGENCY_RETRY_DEFAULT
198 # expire: same robust pattern
199 if expire_raw is None:
200 expire_val: int = _EMERGENCY_EXPIRE_DEFAULT
201 else:
202 try:
203 expire_val = int(expire_raw)
204 except (TypeError, ValueError):
205 _LOGGER.warning(
206 "SUPERNOTIFY pushover: invalid pushover_expire %r, using default %ds",
207 expire_raw,
208 _EMERGENCY_EXPIRE_DEFAULT,
209 )
210 expire_val = _EMERGENCY_EXPIRE_DEFAULT
212 if retry_val < _EMERGENCY_RETRY_MIN:
213 _LOGGER.warning(
214 "SUPERNOTIFY pushover: emergency retry %ds < minimum %ds, clamping",
215 retry_val,
216 _EMERGENCY_RETRY_MIN,
217 )
218 retry_val = _EMERGENCY_RETRY_MIN
220 if expire_val > _EMERGENCY_EXPIRE_MAX:
221 _LOGGER.warning(
222 "SUPERNOTIFY pushover: emergency expire %ds > maximum %ds, clamping",
223 expire_val,
224 _EMERGENCY_EXPIRE_MAX,
225 )
226 expire_val = _EMERGENCY_EXPIRE_MAX
228 push_data["retry"] = retry_val
229 push_data["expire"] = expire_val
231 if callback:
232 push_data["callback"] = callback
234 _LOGGER.debug(
235 "SUPERNOTIFY pushover: emergency mode - retry=%ds expire=%ds",
236 retry_val,
237 expire_val,
238 )
240 # Optional fields - added only when present
241 if sound:
242 push_data["sound"] = sound
243 if url:
244 push_data["url"] = url
245 if url_title:
246 push_data["url_title"] = url_title
247 if html_flag:
248 push_data["html"] = 1
249 if ttl_raw is not None:
250 try:
251 push_data["ttl"] = int(ttl_raw)
252 except (TypeError, ValueError):
253 _LOGGER.warning("SUPERNOTIFY pushover: invalid pushover_ttl %r, ignored", ttl_raw)
254 if device:
255 push_data["device"] = device
257 # --- Camera image attachment via envelope.grab_image() (v1.14.0+) ---
258 if attach_image:
259 try:
260 image_path = await envelope.grab_image()
261 if image_path:
262 push_data["attachment"] = str(image_path)
263 _LOGGER.debug("SUPERNOTIFY pushover: attaching image %s", image_path)
264 except Exception as e:
265 _LOGGER.warning("SUPERNOTIFY pushover: failed to grab image: %s", e)
267 action_data[ATTR_DATA] = push_data
269 # Remaining raw_data is NOT forwarded - Pushover HA service schema is fixed
270 return await self.call_action(envelope, action_data=action_data)