Coverage for custom_components/supernotify/transports/kodi.py: 97%
94 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +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 RE_MEDIA_PLAYER_ENTITY_ID,
62 TRANSPORT_KODI,
63)
64from custom_components.supernotify.model import (
65 DebugTrace,
66 SuppressionReason,
67 TransportConfig,
68 TransportFeature,
69)
70from custom_components.supernotify.options import MEDIA_OPTIONS, OPTION_TARGET_SELECT, DeliveryOption
71from custom_components.supernotify.target import TargetEntityCategory
72from custom_components.supernotify.transport import Transport
74if TYPE_CHECKING:
75 from custom_components.supernotify.envelope import Envelope
76 from custom_components.supernotify.hass_api import HomeAssistantAPI
78_LOGGER = logging.getLogger(__name__)
80HA_KODI_DOMAIN = "kodi"
82# GUI.ShowNotification displaytime constraints (milliseconds)
83DEFAULT_DISPLAYTIME = 10000
84MIN_DISPLAYTIME = 1500
86# SuperNotify priority -> Kodi native notification icon
87_PRIORITY_ICON = {
88 "critical": "error",
89 "high": "warning",
90 "medium": "info",
91 "low": "info",
92 "minimum": "info",
93}
94_DEFAULT_ICON = "info"
97def _coerce_int(value: Any) -> int | None: # ruff: ignore[any-type]
98 """Best-effort int coercion. Returns None on failure."""
99 if value is None:
100 return None
101 try:
102 return int(value)
103 except TypeError, ValueError:
104 try:
105 return int(float(value))
106 except TypeError, ValueError:
107 return None
110class KodiTransport(Transport):
111 """Notify via Kodi on-screen overlay using the kodi integration."""
113 def __init__(self, *args: Any, **kwargs: Any) -> None:
114 super().__init__(*args, **kwargs)
116 name = TRANSPORT_KODI
117 integration_domain = HA_KODI_DOMAIN
118 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
120 @property
121 def supported_features(self) -> TransportFeature:
122 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
124 @property
125 def default_config(self) -> TransportConfig:
126 config = super().default_config
127 config.delivery_defaults.action = "kodi.call_method"
128 config.delivery_defaults.options = {
129 OPTION_TARGET_SELECT: [RE_MEDIA_PLAYER_ENTITY_ID],
130 }
131 return config
133 @property
134 def other_target_categories(self) -> list[str | TargetEntityCategory]:
135 # a bare media_player entity is too ambiguous to auto-select on - kodi, tts, the
136 # `media` transport and alexa_media_player all match the same domain, with no way to
137 # tell from the entity alone which operation (playback, TTS, a chime) was meant
138 return [TargetEntityCategory(domain="media_player")]
140 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
141 return hass_api.find_config_entry_data(HA_KODI_DOMAIN) is not None
143 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
144 return {self.name: {}}
146 def validate_action(self, action: str | None) -> bool:
147 """Validate that action is the kodi call_method service."""
148 return action == "kodi.call_method"
150 def absolute_url(self, url: str) -> str:
151 """Make a relative URL absolute so the Kodi host can fetch it.
153 Prefers the HA internal URL since Kodi is typically a LAN device;
154 falls back to the external URL. Absolute URLs pass through as-is.
155 """
156 if not url or url.startswith(("http://", "https://")):
157 return url
158 base = self.hass_api.internal_url or self.hass_api.external_url
159 if not base:
160 _LOGGER.warning("SUPERNOTIFY kodi: no base url to absolutise %s", url)
161 return url
162 if not url.startswith("/"):
163 url = "/" + url
164 return urllib.parse.urljoin(base, url)
166 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
167 _LOGGER.debug("SUPERNOTIFY kodi %s", envelope.message)
169 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
171 # Pop Kodi-specific data keys
172 displaytime_raw = raw_data.pop("kodi_displaytime", None)
173 icon_override = raw_data.pop("kodi_icon", None)
174 attach_image = boolify(raw_data.pop("kodi_attach_image", False), default=False)
176 # Resolve and pre-validate media_player targets
177 targets = envelope.target.entity_ids if envelope.target else []
178 if not targets:
179 _LOGGER.warning("SUPERNOTIFY kodi: no valid media_player targets")
180 envelope.skipped = 1
181 envelope.skip_reason = SuppressionReason.NO_TARGET
182 return False
184 # Coerce displaytime and clamp to the Kodi minimum
185 displaytime = _coerce_int(displaytime_raw)
186 if displaytime is None:
187 if displaytime_raw is not None:
188 _LOGGER.warning(
189 "SUPERNOTIFY kodi: invalid kodi_displaytime %r, using default %d ms",
190 displaytime_raw,
191 DEFAULT_DISPLAYTIME,
192 )
193 displaytime = DEFAULT_DISPLAYTIME
194 if displaytime < MIN_DISPLAYTIME:
195 _LOGGER.debug(
196 "SUPERNOTIFY kodi: kodi_displaytime %d below Kodi minimum, clamping to %d ms",
197 displaytime,
198 MIN_DISPLAYTIME,
199 )
200 displaytime = MIN_DISPLAYTIME
202 # Icon: priority-derived default, then kodi_icon override, then
203 # snapshot image URL (which wins when resolvable)
204 icon: str = _PRIORITY_ICON.get(envelope.priority or "medium", _DEFAULT_ICON)
205 if icon_override:
206 icon = str(icon_override)
207 if attach_image:
208 image_url = await self.image_url(envelope)
209 if image_url:
210 icon = image_url
211 else:
212 _LOGGER.debug("SUPERNOTIFY kodi: no image URL available, keeping icon %r", icon)
214 # Build the JSON-RPC payload. Every key in action_data beyond
215 # `method` is forwarded as a GUI.ShowNotification parameter, so
216 # residual generic data keys are NOT merged: an unknown parameter
217 # fails the whole JSON-RPC call on the Kodi side.
218 action_data: dict[str, Any] = {
219 "method": "GUI.ShowNotification",
220 # GUI.ShowNotification requires a non-empty title
221 "title": envelope.title or await self.common_string("default_title"),
222 "message": envelope.message or "",
223 "image": icon,
224 "displaytime": displaytime,
225 }
227 if raw_data:
228 _LOGGER.debug(
229 "SUPERNOTIFY kodi: dropping data keys not supported by GUI.ShowNotification: %s",
230 sorted(raw_data),
231 )
233 return await self.call_action(
234 envelope,
235 action_data=action_data,
236 target_data={ATTR_ENTITY_ID: targets},
237 )