Coverage for custom_components/supernotify/transports/kodi.py: 96%
114 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"""Kodi transport for SuperNotify.
3Shows on-screen overlay notifications on Kodi media centers using Home
4Assistant's `kodi` integration, calling the entity-based `kodi.call_method`
5service with the JSON-RPC method `GUI.ShowNotification`.
7Supported data keys (all optional):
8 kodi_displaytime int overlay duration in milliseconds
9 (default: 10000). Kodi enforces a minimum
10 of 1500 ms, lower values are clamped.
11 kodi_icon str "info" | "warning" | "error" or an image
12 URL. Overrides the priority-derived icon.
13 kodi_attach_image bool use the camera snapshot as the notification
14 icon, resolved to a URL reachable from the
15 Kodi host (default: False). When an image
16 URL is resolved it wins over kodi_icon.
18Priority to native icon mapping (when kodi_icon is not set):
19 critical -> "error", high -> "warning",
20 medium / low / minimum -> "info"
22Notes on the `kodi.call_method` service:
23- The service is entity-based: Kodi instances are `media_player` entities
24 created by the kodi config entry. Targets are pre-filtered here to
25 `media_player.*` entity ids and passed via target_data; other targets
26 are dropped with a debug log.
27- `GUI.ShowNotification` requires a non-empty `title`: when the envelope
28 has no title, a "Notification" fallback is used.
29- Kodi runs on a remote host, so the snapshot image must be a URL that the
30 Kodi box can fetch, NEVER a local HA filesystem path. Resolution order:
31 1. `envelope.media[snapshot_url]` (absolutised against the HA base URL)
32 2. `envelope.grab_image()` + `media_storage.object_url()` (already an
33 absolute URL served by the HA web server)
34 The HA Internal URL should be a direct IP (e.g. http://192.168.0.123:8123)
35 rather than an mDNS `.local` hostname, which some players cannot resolve.
36- No residual data passthrough: any extra key in the payload is forwarded
37 by `kodi.call_method` as a JSON-RPC parameter and makes the whole
38 `GUI.ShowNotification` call fail on the Kodi side. Residual generic data
39 keys are therefore dropped with a debug log, unlike the standard
40 transport pattern.
41- Pure overlay: no action buttons and no user interaction.
44References:
45- Kodi integration: https://www.home-assistant.io/integrations/kodi/
46- JSON-RPC GUI.ShowNotification: https://kodi.wiki/view/JSON-RPC_API
48"""
50from __future__ import annotations
52import logging
53import urllib.parse
54from typing import TYPE_CHECKING, Any, ClassVar
56from homeassistant.const import ATTR_ENTITY_ID
57from homeassistant.helpers.typing import ConfigType
59from custom_components.supernotify.common import boolify
60from custom_components.supernotify.const import (
61 ATTR_MEDIA_SNAPSHOT_URL,
62 RE_MEDIA_PLAYER_ENTITY_ID,
63 TRANSPORT_KODI,
64)
65from custom_components.supernotify.model import (
66 DebugTrace,
67 TargetRequired,
68 TransportConfig,
69 TransportFeature,
70)
71from custom_components.supernotify.options import MEDIA_OPTIONS, OPTION_TARGET_SELECT, DeliveryOption
72from custom_components.supernotify.target import TargetEntityCategory
73from custom_components.supernotify.transport import Transport
75if TYPE_CHECKING:
76 from custom_components.supernotify.envelope import Envelope
77 from custom_components.supernotify.hass_api import HomeAssistantAPI
79_LOGGER = logging.getLogger(__name__)
81HA_KODI_DOMAIN = "kodi"
83# GUI.ShowNotification displaytime constraints (milliseconds)
84DEFAULT_DISPLAYTIME = 10000
85MIN_DISPLAYTIME = 1500
87# GUI.ShowNotification requires a non-empty title
88DEFAULT_TITLE = "Notification"
90# SuperNotify priority -> Kodi native notification icon
91_PRIORITY_ICON = {
92 "critical": "error",
93 "high": "warning",
94 "medium": "info",
95 "low": "info",
96 "minimum": "info",
97}
98_DEFAULT_ICON = "info"
101def _coerce_int(value: Any) -> int | None: # ruff: ignore[any-type]
102 """Best-effort int coercion. Returns None on failure."""
103 if value is None:
104 return None
105 try:
106 return int(value)
107 except (TypeError, ValueError):
108 try:
109 return int(float(value))
110 except (TypeError, ValueError):
111 return None
114class KodiTransport(Transport):
115 """Notify via Kodi on-screen overlay using the kodi integration."""
117 def __init__(self, *args: Any, **kwargs: Any) -> None:
118 super().__init__(*args, **kwargs)
120 name = TRANSPORT_KODI
121 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
123 @property
124 def supported_features(self) -> TransportFeature:
125 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
127 @property
128 def default_config(self) -> TransportConfig:
129 config = TransportConfig()
130 config.delivery_defaults.action = "kodi.call_method"
131 config.delivery_defaults.target_required = TargetRequired.ALWAYS
132 config.delivery_defaults.options = {
133 OPTION_TARGET_SELECT: [RE_MEDIA_PLAYER_ENTITY_ID],
134 }
135 config.delivery_defaults.inclusion = self.inclusion_mode
136 return config
138 @property
139 def target_categories(self) -> list[str | TargetEntityCategory]:
140 return [TargetEntityCategory(domain="media_player")]
142 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
143 return hass_api.find_config_entry_data(HA_KODI_DOMAIN) is not None
145 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
146 return {self.name: {}}
148 def validate_action(self, action: str | None) -> bool:
149 """Validate that action is the kodi call_method service."""
150 return action == "kodi.call_method"
152 def _absolute_url(self, url: str) -> str:
153 """Make a relative URL absolute so the Kodi host can fetch it.
155 Prefers the HA internal URL since Kodi is typically a LAN device;
156 falls back to the external URL. Absolute URLs pass through as-is.
157 """
158 if not url or url.startswith(("http://", "https://")):
159 return url
160 base = self.hass_api.internal_url or self.hass_api.external_url
161 if not base:
162 _LOGGER.warning("SUPERNOTIFY kodi: no base url to absolutise %s", url)
163 return url
164 if not url.startswith("/"):
165 url = "/" + url
166 return urllib.parse.urljoin(base, url)
168 async def _resolve_image_url(self, envelope: Envelope) -> str | None:
169 """Resolve a snapshot image URL reachable from the Kodi host.
171 Order of resolution:
172 1. envelope.media snapshot_url (absolutised against HA base URL)
173 2. envelope.grab_image() + media_storage.object_url() (served by
174 the HA web server via the registered media path)
175 3. None (caller keeps the priority/override icon)
176 """
177 snapshot_url = envelope.media.get(ATTR_MEDIA_SNAPSHOT_URL) if envelope.media else None
178 if snapshot_url:
179 return self._absolute_url(str(snapshot_url))
181 image_path = None
182 try:
183 image_path = await envelope.grab_image()
184 except Exception as e:
185 _LOGGER.warning("SUPERNOTIFY kodi: failed to grab image: %s", e)
186 if image_path:
187 try:
188 object_url = await self.context.media_storage.object_url(image_path)
189 except Exception as e:
190 _LOGGER.debug("SUPERNOTIFY kodi: object_url failed for %s: %s", image_path, e)
191 object_url = None
192 if object_url:
193 return object_url
194 _LOGGER.debug("SUPERNOTIFY kodi: no shareable URL for %s, image skipped", image_path)
195 return None
197 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
198 _LOGGER.debug("SUPERNOTIFY kodi %s", envelope.message)
200 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
202 # Pop Kodi-specific data keys
203 displaytime_raw = raw_data.pop("kodi_displaytime", None)
204 icon_override = raw_data.pop("kodi_icon", None)
205 attach_image = boolify(raw_data.pop("kodi_attach_image", False), default=False)
207 # Resolve and pre-validate media_player targets
208 targets = envelope.target.entity_ids if envelope.target else []
209 if not targets:
210 _LOGGER.warning("SUPERNOTIFY kodi: no valid media_player targets")
211 self.record_error("no valid Kodi media_player targets", "deliver")
212 return False
214 # Coerce displaytime and clamp to the Kodi minimum
215 displaytime = _coerce_int(displaytime_raw)
216 if displaytime is None:
217 if displaytime_raw is not None:
218 _LOGGER.warning(
219 "SUPERNOTIFY kodi: invalid kodi_displaytime %r, using default %d ms",
220 displaytime_raw,
221 DEFAULT_DISPLAYTIME,
222 )
223 displaytime = DEFAULT_DISPLAYTIME
224 if displaytime < MIN_DISPLAYTIME:
225 _LOGGER.debug(
226 "SUPERNOTIFY kodi: kodi_displaytime %d below Kodi minimum, clamping to %d ms",
227 displaytime,
228 MIN_DISPLAYTIME,
229 )
230 displaytime = MIN_DISPLAYTIME
232 # Icon: priority-derived default, then kodi_icon override, then
233 # snapshot image URL (which wins when resolvable)
234 icon: str = _PRIORITY_ICON.get(envelope.priority or "medium", _DEFAULT_ICON)
235 if icon_override:
236 icon = str(icon_override)
237 if attach_image:
238 image_url = await self._resolve_image_url(envelope)
239 if image_url:
240 icon = image_url
241 else:
242 _LOGGER.debug("SUPERNOTIFY kodi: no image URL available, keeping icon %r", icon)
244 # Build the JSON-RPC payload. Every key in action_data beyond
245 # `method` is forwarded as a GUI.ShowNotification parameter, so
246 # residual generic data keys are NOT merged: an unknown parameter
247 # fails the whole JSON-RPC call on the Kodi side.
248 action_data: dict[str, Any] = {
249 "method": "GUI.ShowNotification",
250 "title": envelope.title or DEFAULT_TITLE,
251 "message": envelope.message or "",
252 "image": icon,
253 "displaytime": displaytime,
254 }
256 if raw_data:
257 _LOGGER.debug(
258 "SUPERNOTIFY kodi: dropping data keys not supported by GUI.ShowNotification: %s",
259 sorted(raw_data),
260 )
262 return await self.call_action(
263 envelope,
264 action_data=action_data,
265 target_data={ATTR_ENTITY_ID: targets},
266 )