Coverage for custom_components/supernotify/transport.py: 98%
137 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +0000
1from __future__ import annotations
3import datetime as dt
4import logging
5import time
6import unicodedata
7from abc import abstractmethod
8from traceback import format_exception
9from typing import TYPE_CHECKING, Any
10from urllib.parse import urlparse
12from homeassistant.components.notify.const import ATTR_TARGET
13from homeassistant.const import (
14 ATTR_ENTITY_ID,
15 ATTR_FRIENDLY_NAME,
16 ATTR_NAME,
17)
18from homeassistant.exceptions import IntegrationError
19from homeassistant.util import dt as dt_util
21from custom_components.supernotify.model import (
22 DebugTrace,
23 Target,
24 TargetRequired,
25 TransportConfig,
26 TransportFeature,
27)
29from .common import CallRecord
30from .const import (
31 ATTR_ENABLED,
32 CONF_DELIVERY_DEFAULTS,
33)
34from .model import DeliveryConfig, SuppressionReason
36if TYPE_CHECKING:
37 from homeassistant.helpers.typing import ConfigType
39 from .context import Context
40 from .delivery import Delivery, DeliveryRegistry
41 from .hass_api import HomeAssistantAPI
42 from .people import PeopleRegistry
44_LOGGER = logging.getLogger(__name__)
47class Transport:
48 """Base class for delivery transports.
50 Sub classes integrste with Home Assistant notification services
51 or alternative notification mechanisms.
52 """
54 name: str
56 @abstractmethod
57 def __init__(self, context: Context, transport_config: ConfigType | None = None) -> None:
58 self.hass_api: HomeAssistantAPI = context.hass_api
59 self.people_registry: PeopleRegistry = context.people_registry
60 self.delivery_registry: DeliveryRegistry = context.delivery_registry
61 self.context: Context = context
62 transport_config = transport_config or {}
63 self.transport_config = TransportConfig(transport_config, class_config=self.default_config)
65 self.delivery_defaults: DeliveryConfig = self.transport_config.delivery_defaults
66 self.config_enabled = self.transport_config.enabled
67 self.enabled = self.config_enabled
68 self.alias = self.transport_config.alias
69 self.last_error_at: dt.datetime | None = None
70 self.last_error_in: str | None = None
71 self.last_error_message: str | None = None
72 self.error_count: int = 0
73 self._unavailable: bool = False
75 async def initialize(self) -> None:
76 """Async post-construction initialization"""
77 if self.name is None:
78 raise IntegrationError("Invalid nameless transport adaptor subclass")
80 def setup_delivery_options(self, options: dict[str, Any], delivery_name: str) -> dict[str, Any]:
81 return {}
83 @property
84 def supported_features(self) -> TransportFeature:
85 return TransportFeature.MESSAGE | TransportFeature.TITLE
87 @property
88 def targets(self) -> Target:
89 return self.delivery_defaults.target if self.delivery_defaults.target is not None else Target()
91 @property
92 def default_config(self) -> TransportConfig:
93 return TransportConfig()
95 def auto_configure(self, hass_api: HomeAssistantAPI) -> DeliveryConfig | None:
96 return None
98 def validate_action(self, action: str | None) -> bool:
99 """Override in subclass if transport has fixed action or doesn't require one"""
100 return action == self.delivery_defaults.action
102 def attributes(self) -> dict[str, Any]:
103 attrs: dict[str, Any] = {
104 ATTR_NAME: self.name,
105 ATTR_ENABLED: self.enabled,
106 CONF_DELIVERY_DEFAULTS: self.delivery_defaults,
107 }
108 if self.alias:
109 attrs[ATTR_FRIENDLY_NAME] = self.alias
110 if self.last_error_at:
111 attrs["last_error_at"] = self.last_error_at
112 attrs["last_error_in"] = self.last_error_in
113 attrs["last_error_message"] = self.last_error_message
114 attrs["error_count"] = self.error_count
115 attrs.update(self.extra_attributes())
116 return attrs
118 def extra_attributes(self) -> dict[str, Any]:
119 return {}
121 @abstractmethod
122 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: # type: ignore # noqa: F821
123 """Delivery implementation
125 Args:
126 ----
127 envelope (Envelope): envelope to be delivered
128 debug_trace (DebugTrace): debug info collector
130 """
132 def set_action_data(self, action_data: dict[str, Any], key: str, data: Any | None) -> Any:
133 if data is not None:
134 action_data[key] = data
135 return action_data
137 async def call_action(
138 self,
139 envelope: Envelope, # type: ignore # noqa: F821
140 qualified_action: str | None = None,
141 action_data: dict[str, Any] | None = None,
142 target_data: dict[str, Any] | None = None,
143 implied_target: bool = False, # True if the qualified action implies a target
144 ) -> bool:
145 action_data = action_data or {}
146 start_time = time.time()
147 domain = service = None
148 delivery: Delivery = envelope.delivery
149 try:
150 qualified_action = qualified_action or delivery.action
151 if not qualified_action:
152 _LOGGER.debug(
153 "SUPERNOTIFY Skipping %s action call with no service, targets %s",
154 envelope.delivery.name,
155 action_data.get(ATTR_TARGET),
156 )
157 envelope.skipped = 1
158 envelope.skip_reason = SuppressionReason.NO_ACTION
159 return False
160 if (
161 delivery.target_required == TargetRequired.ALWAYS
162 and not action_data.get(ATTR_TARGET)
163 and not action_data.get(ATTR_ENTITY_ID)
164 and not implied_target
165 and not target_data
166 ):
167 _LOGGER.debug(
168 "SUPERNOTIFY Skipping %s action call for service %s, missing targets",
169 envelope.delivery.name,
170 qualified_action,
171 )
172 envelope.skipped = 1
173 envelope.skip_reason = SuppressionReason.NO_TARGET
174 return False
176 domain, service = qualified_action.split(".", 1)
177 start_time = time.time()
178 timestamp: dt.datetime | None = None
179 if target_data:
180 # home-assistant messes with the service_data passed by ref
181 service_data_as_sent = dict(action_data)
182 timestamp = dt.datetime.now(tz=dt_util.get_default_time_zone())
183 service_response = await self.hass_api.call_service(
184 domain, service, service_data=action_data, target=target_data, debug=delivery.debug
185 )
186 envelope.calls.append(
187 CallRecord(
188 timestamp,
189 time.time() - start_time,
190 domain,
191 service,
192 debug=delivery.debug,
193 action_data=service_data_as_sent,
194 target_data=target_data,
195 service_response=service_response,
196 )
197 )
198 else:
199 service_data_as_sent = dict(action_data)
200 timestamp = dt.datetime.now(tz=dt_util.get_default_time_zone())
201 service_response = await self.hass_api.call_service(
202 domain, service, service_data=action_data, debug=delivery.debug
203 )
204 envelope.calls.append(
205 CallRecord(
206 timestamp,
207 time.time() - start_time,
208 domain,
209 service,
210 debug=delivery.debug,
211 action_data=service_data_as_sent,
212 service_response=service_response,
213 )
214 )
216 envelope.delivered = 1
217 self.log_delivery_recovered()
218 return True
219 except Exception as e:
220 self.record_error(str(e), method="call_action")
221 envelope.failed_calls.append(
222 CallRecord(
223 timestamp,
224 time.time() - start_time,
225 domain,
226 service,
227 action_data,
228 target_data,
229 exception=str(e),
230 )
231 )
232 self.log_delivery_failure(
233 e, "SUPERNOTIFY Failed to notify %s via %s, data=%s", self.name, qualified_action, action_data
234 )
235 envelope.error_count += 1
236 envelope.delivery_error = format_exception(e)
237 return False
239 def record_error(self, message: str, method: str) -> None:
240 self.last_error_at = dt_util.utcnow()
241 self.last_error_message = message
242 self.last_error_in = method
243 self.error_count += 1
245 def log_delivery_failure(self, err: BaseException, message: str, *args: Any) -> None:
246 """Log a delivery failure, passing the exception caught in the caller's except block.
248 Logged at ERROR (with traceback) the first time this transport becomes unavailable,
249 then downgraded to DEBUG for consecutive failures until it recovers - avoids
250 spamming the log every notification while an external service/device stays down.
251 Call alongside record_error(), which keeps tracking the lifetime error count
252 regardless of log level.
253 """
254 if self._unavailable:
255 _LOGGER.debug(message, *args, exc_info=err)
256 else:
257 _LOGGER.error(message, *args, exc_info=err)
258 self._unavailable = True
260 def log_delivery_recovered(self) -> None:
261 """Call on a successful delivery - logs once if this transport was previously
262 flagged unavailable, then clears the flag."""
263 if self._unavailable:
264 _LOGGER.info("SUPERNOTIFY %s transport recovered after prior delivery failures", self.name)
265 self._unavailable = False
267 def simplify(self, text: str | None, strip_urls: bool = False) -> str | None:
268 """Simplify text for delivery transports with speaking or plain text interfaces"""
269 if not text:
270 return None
271 if strip_urls:
272 words = text.split()
273 text = " ".join(word for word in words if not urlparse(word).scheme)
274 text = text.translate(str.maketrans("_", " ", "()£$<>"))
275 text = "".join(c for c in text if unicodedata.category(c) not in ("So", "Sk", "Sm", "Mn"))
276 _LOGGER.debug("SUPERNOTIFY Simplified text to: %s", text)
277 return text