Coverage for custom_components/supernotify/transports/kodi.py: 96%

114 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +0000

1"""Kodi transport for SuperNotify. 

2 

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`. 

6 

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. 

17 

18Priority to native icon mapping (when kodi_icon is not set): 

19 critical -> "error", high -> "warning", 

20 medium / low / minimum -> "info" 

21 

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. 

42 

43 

44References: 

45- Kodi integration: https://www.home-assistant.io/integrations/kodi/ 

46- JSON-RPC GUI.ShowNotification: https://kodi.wiki/view/JSON-RPC_API 

47 

48""" 

49 

50from __future__ import annotations 

51 

52import logging 

53import urllib.parse 

54from typing import TYPE_CHECKING, Any, ClassVar 

55 

56from homeassistant.const import ATTR_ENTITY_ID 

57from homeassistant.helpers.typing import ConfigType 

58 

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 

74 

75if TYPE_CHECKING: 

76 from custom_components.supernotify.envelope import Envelope 

77 from custom_components.supernotify.hass_api import HomeAssistantAPI 

78 

79_LOGGER = logging.getLogger(__name__) 

80 

81HA_KODI_DOMAIN = "kodi" 

82 

83# GUI.ShowNotification displaytime constraints (milliseconds) 

84DEFAULT_DISPLAYTIME = 10000 

85MIN_DISPLAYTIME = 1500 

86 

87# GUI.ShowNotification requires a non-empty title 

88DEFAULT_TITLE = "Notification" 

89 

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" 

99 

100 

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 

112 

113 

114class KodiTransport(Transport): 

115 """Notify via Kodi on-screen overlay using the kodi integration.""" 

116 

117 def __init__(self, *args: Any, **kwargs: Any) -> None: 

118 super().__init__(*args, **kwargs) 

119 

120 name = TRANSPORT_KODI 

121 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS] 

122 

123 @property 

124 def supported_features(self) -> TransportFeature: 

125 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE 

126 

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 

137 

138 @property 

139 def target_categories(self) -> list[str | TargetEntityCategory]: 

140 return [TargetEntityCategory(domain="media_player")] 

141 

142 def is_viable(self, hass_api: HomeAssistantAPI) -> bool: 

143 return hass_api.find_config_entry_data(HA_KODI_DOMAIN) is not None 

144 

145 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]: 

146 return {self.name: {}} 

147 

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" 

151 

152 def _absolute_url(self, url: str) -> str: 

153 """Make a relative URL absolute so the Kodi host can fetch it. 

154 

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) 

167 

168 async def _resolve_image_url(self, envelope: Envelope) -> str | None: 

169 """Resolve a snapshot image URL reachable from the Kodi host. 

170 

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)) 

180 

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 

196 

197 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: 

198 _LOGGER.debug("SUPERNOTIFY kodi %s", envelope.message) 

199 

200 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {} 

201 

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) 

206 

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 

213 

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 

231 

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) 

243 

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 } 

255 

256 if raw_data: 

257 _LOGGER.debug( 

258 "SUPERNOTIFY kodi: dropping data keys not supported by GUI.ShowNotification: %s", 

259 sorted(raw_data), 

260 ) 

261 

262 return await self.call_action( 

263 envelope, 

264 action_data=action_data, 

265 target_data={ATTR_ENTITY_ID: targets}, 

266 )