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
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-11 22:18 +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:` in delivery.yaml — 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
58from homeassistant.components.notify.const import ATTR_DATA
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
70if TYPE_CHECKING:
71 from custom_components.supernotify.envelope import Envelope
73_LOGGER = logging.getLogger(__name__)
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}
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)
91class PushoverTransport(Transport):
92 """Notify via Pushover push notification service."""
94 name = TRANSPORT_PUSHOVER
96 def __init__(self, *args: Any, **kwargs: Any) -> None:
97 super().__init__(*args, **kwargs)
99 @property
100 def supported_features(self) -> TransportFeature:
101 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
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
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
119 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: # noqa: ARG002
120 _LOGGER.debug("SUPERNOTIFY pushover %s", envelope.message)
122 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
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)
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
155 pushover_priority: int = (
156 priority_ovr if priority_ovr is not None else _PRIORITY_MAP.get(envelope.priority or "medium", 0)
157 )
159 # --- Base action data (includes message and title) ---
160 action_data = envelope.core_action_data()
162 # --- Pushover-specific data payload ---
163 push_data: dict[str, Any] = {"priority": pushover_priority}
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
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
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
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
211 push_data["retry"] = retry_val
212 push_data["expire"] = expire_val
214 if callback:
215 push_data["callback"] = callback
217 _LOGGER.debug(
218 "SUPERNOTIFY pushover: emergency mode - retry=%ds expire=%ds",
219 retry_val,
220 expire_val,
221 )
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
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)
250 action_data[ATTR_DATA] = push_data
252 # Remaining raw_data is NOT forwarded - Pushover HA service schema is fixed
253 return await self.call_action(envelope, action_data=action_data)