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

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

73 

74if TYPE_CHECKING: 

75 from custom_components.supernotify.envelope import Envelope 

76 from custom_components.supernotify.hass_api import HomeAssistantAPI 

77 

78_LOGGER = logging.getLogger(__name__) 

79 

80HA_KODI_DOMAIN = "kodi" 

81 

82# GUI.ShowNotification displaytime constraints (milliseconds) 

83DEFAULT_DISPLAYTIME = 10000 

84MIN_DISPLAYTIME = 1500 

85 

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" 

95 

96 

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 

108 

109 

110class KodiTransport(Transport): 

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

112 

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

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

115 

116 name = TRANSPORT_KODI 

117 integration_domain = HA_KODI_DOMAIN 

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

119 

120 @property 

121 def supported_features(self) -> TransportFeature: 

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

123 

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 

132 

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

139 

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

141 return hass_api.find_config_entry_data(HA_KODI_DOMAIN) is not None 

142 

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

144 return {self.name: {}} 

145 

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" 

149 

150 def absolute_url(self, url: str) -> str: 

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

152 

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) 

165 

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

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

168 

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

170 

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) 

175 

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 

183 

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 

201 

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) 

213 

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 } 

226 

227 if raw_data: 

228 _LOGGER.debug( 

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

230 sorted(raw_data), 

231 ) 

232 

233 return await self.call_action( 

234 envelope, 

235 action_data=action_data, 

236 target_data={ATTR_ENTITY_ID: targets}, 

237 )