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

195 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-11 22:18 +0000

1from __future__ import annotations 

2 

3import logging 

4from dataclasses import dataclass, field 

5from typing import TYPE_CHECKING, Any 

6 

7from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN 

8from homeassistant.components.notify.const import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE 

9 

10# ATTR_VARIABLES from script.const has import issues 

11from homeassistant.const import ATTR_ENTITY_ID 

12 

13from custom_components.supernotify.common import ensure_list 

14from custom_components.supernotify.const import ( 

15 ATTR_ACTION_URL, 

16 ATTR_ACTIONS, 

17 ATTR_MEDIA, 

18 ATTR_MEDIA_SNAPSHOT_URL, 

19 ATTR_PRIORITY, 

20 OPTION_DATA_KEYS_SELECT, 

21 OPTION_GENERIC_DOMAIN_STYLE, 

22 OPTION_MESSAGE_USAGE, 

23 OPTION_RAW, 

24 OPTION_SIMPLIFY_TEXT, 

25 OPTION_STRIP_URLS, 

26 OPTION_TARGET_CATEGORIES, 

27 PRIORITY_CRITICAL, 

28 PRIORITY_HIGH, 

29 PRIORITY_LOW, 

30 PRIORITY_MEDIUM, 

31 PRIORITY_MINIMUM, 

32 PRIORITY_VALUES, 

33 TRANSPORT_GENERIC, 

34) 

35from custom_components.supernotify.model import ( 

36 DataFilter, 

37 DebugTrace, 

38 MessageOnlyPolicy, 

39 Target, 

40 TargetRequired, 

41 TransportConfig, 

42 TransportFeature, 

43) 

44from custom_components.supernotify.transport import ( 

45 Transport, 

46) 

47 

48if TYPE_CHECKING: 

49 from custom_components.supernotify.delivery import Delivery 

50 from custom_components.supernotify.envelope import Envelope 

51 from custom_components.supernotify.hass_api import HomeAssistantAPI 

52 

53_LOGGER = logging.getLogger(__name__) 

54""" 

55Replaced by reuse of original service schema to prune out fields 

56 

57DATA_FIELDS_ALLOWED_BY_DOMAIN = { 

58 "light": [ 

59 "transition", 

60 "rgb_color", 

61 "color_temp_kelvin", 

62 "brightness_pct", 

63 "brightness_step_pct", 

64 "effect", 

65 "rgbw_color", 

66 "rgbww_color", 

67 "color_name", 

68 "hs_color", 

69 "xy_color", 

70 "color_temp", 

71 "brightness", 

72 "brightness_step", 

73 "white", 

74 "profile", 

75 "flash", 

76 ], 

77 "siren": ["tone", "duration", "volume_level"], 

78 "mqtt": ["topic", "payload", "evaluate_payload", "qos", "retain"], 

79 "script": ["variables", "wait", "wait_template"], 

80 "ntfy": [ 

81 "title", 

82 "message", 

83 "markdown", 

84 "tags", 

85 "priority", 

86 "click", 

87 "delay", 

88 "attach", 

89 "attach_file", 

90 "filename", 

91 "email", 

92 "call", 

93 "icon", 

94 "action", 

95 "sequence_id", 

96 ], 

97 "tts": ["cache", "options", "message", "language", "media_player_entity_id", "entity_id", "target"], 

98} """ 

99 

100 

101class GenericTransport(Transport): 

102 """Call any service, including non-notify ones, like switch.turn_on or mqtt.publish""" 

103 

104 name = TRANSPORT_GENERIC 

105 

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

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

108 

109 @property 

110 def supported_features(self) -> TransportFeature: 

111 return TransportFeature.MESSAGE | TransportFeature.TITLE 

112 

113 @property 

114 def default_config(self) -> TransportConfig: 

115 config = TransportConfig() 

116 config.delivery_defaults.target_required = TargetRequired.OPTIONAL 

117 config.delivery_defaults.options = { 

118 OPTION_SIMPLIFY_TEXT: False, 

119 OPTION_STRIP_URLS: False, 

120 OPTION_RAW: False, 

121 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.STANDARD, 

122 OPTION_DATA_KEYS_SELECT: None, 

123 OPTION_GENERIC_DOMAIN_STYLE: None, 

124 } 

125 return config 

126 

127 def validate_action(self, action: str | None) -> bool: 

128 if action is not None and "." in action: 

129 return True 

130 _LOGGER.warning("SUPERNOTIFY generic transport must have a qualified action name, e.g. notify.foo") 

131 return False 

132 

133 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: # noqa: ARG002 

134 # inputs 

135 data: dict[str, Any] = envelope.data or {} 

136 core_action_data: dict[str, Any] = envelope.core_action_data(force_message=False) 

137 raw_mode: bool = envelope.delivery.options.get(OPTION_RAW, False) 

138 qualified_action: str | None = envelope.delivery.action 

139 split_action = ( 

140 qualified_action.split(".", 1) if qualified_action and "." in qualified_action else [None, qualified_action] 

141 ) 

142 domain: str | None = split_action[0] 

143 service: str | None = split_action[1] 

144 

145 equiv_domain: str | None = domain 

146 if envelope.delivery.options.get(OPTION_GENERIC_DOMAIN_STYLE): 

147 equiv_domain = envelope.delivery.options.get(OPTION_GENERIC_DOMAIN_STYLE) 

148 _LOGGER.debug("SUPERNOTIFY Handling %s generic message as if it was %s", domain, equiv_domain) 

149 

150 # outputs 

151 action_data: dict[str, Any] = {} 

152 target_data: dict[str, Any] | None = {} 

153 build_targets: bool = False 

154 prune_data: bool = True 

155 mini_envelopes: list[MiniEnvelope] = [] # only used for script and ntfy 

156 

157 if raw_mode: 

158 action_data = core_action_data 

159 action_data.update(data) 

160 build_targets = True 

161 elif equiv_domain == "notify": 

162 action_data = core_action_data 

163 if qualified_action == "notify.send_message": 

164 # amongst the wild west of notifty handling, at least care for the modern core one 

165 action_data = core_action_data 

166 target_data = {ATTR_ENTITY_ID: envelope.target.domain_entity_ids(domain)} 

167 prune_data = False 

168 else: 

169 action_data = core_action_data 

170 action_data[ATTR_DATA] = data 

171 build_targets = True 

172 elif equiv_domain == "input_text": 

173 target_data = {ATTR_ENTITY_ID: envelope.target.domain_entity_ids(domain)} 

174 if "value" in data: 

175 action_data = {"value": data["value"]} 

176 else: 

177 action_data = {"value": core_action_data[ATTR_MESSAGE]} 

178 elif equiv_domain == "switch": 

179 target_data = {ATTR_ENTITY_ID: envelope.target.domain_entity_ids(domain)} 

180 elif equiv_domain == "mqtt": 

181 action_data = data 

182 if "payload" not in action_data: 

183 action_data["payload"] = envelope.message 

184 # add `payload:` with empty value for empty topic 

185 elif equiv_domain == "tts": 

186 action_data = core_action_data 

187 action_data.update(data) 

188 build_targets = True 

189 elif equiv_domain == "notify_events": 

190 mini_envelopes.extend(notify_events(envelope.message, envelope.title, core_action_data, data, envelope.delivery)) 

191 elif qualified_action == "ntfy.publish": 

192 mini_envelopes.extend(ntfy(core_action_data, data, envelope.target, envelope.delivery, self.hass_api)) 

193 elif equiv_domain in ("siren", "light"): 

194 target_data = {ATTR_ENTITY_ID: envelope.target.domain_entity_ids(domain)} 

195 action_data = data 

196 elif equiv_domain == "rest_command": 

197 action_data = data 

198 elif equiv_domain == "script": 

199 mini_envelopes.extend( 

200 script(qualified_action, core_action_data, data, envelope.target, envelope.delivery, self.hass_api) 

201 ) 

202 else: 

203 action_data = core_action_data 

204 action_data.update(data) 

205 build_targets = True 

206 

207 if mini_envelopes: 

208 results: list[bool] = [ 

209 await self.call_action( 

210 envelope, qualified_action, action_data=mini_envelope.action_data, target_data=mini_envelope.target_data 

211 ) 

212 for mini_envelope in mini_envelopes 

213 ] 

214 return all(results) 

215 

216 if build_targets: 

217 all_targets: list[str] = [] 

218 if OPTION_TARGET_CATEGORIES in envelope.delivery.options: 

219 for category in ensure_list(envelope.delivery.options.get(OPTION_TARGET_CATEGORIES, [])): 

220 all_targets.extend(envelope.target.for_category(category)) 

221 else: 

222 all_targets = envelope.target.resolved_targets() 

223 if len(all_targets) == 1: 

224 action_data[ATTR_TARGET] = all_targets[0] 

225 elif len(all_targets) >= 1: 

226 action_data[ATTR_TARGET] = all_targets 

227 

228 if prune_data and action_data: 

229 action_data = envelope.customize_data(action_data) 

230 if not raw_mode and domain and service: 

231 # use the service schema to remove unsupported fields or force type 

232 action_data = self.context.hass_api.coerce_schema(domain, service, action_data) 

233 

234 return await self.call_action(envelope, qualified_action, action_data=action_data, target_data=target_data or None) 

235 

236 

237@dataclass 

238class MiniEnvelope: 

239 action_data: dict[str, Any] = field(default_factory=dict) 

240 target_data: dict[str, Any] | None = None 

241 

242 

243def script( 

244 qualified_action: str | None, 

245 core_action_data: dict[str, Any], 

246 data: dict[str, Any], 

247 target: Target, 

248 delivery: Delivery, # noqa: ARG001 

249 hass_api: HomeAssistantAPI, 

250) -> list[MiniEnvelope]: 

251 """Customize `data` for script integration""" 

252 results: list[MiniEnvelope] = [] 

253 if qualified_action in ("script.turn_on", "script.turn_off"): 

254 action_data = {} 

255 action_data["variables"] = core_action_data 

256 if "variables" in data: 

257 action_data["variables"].update(data.pop("variables")) 

258 action_data["variables"].update(data) 

259 action_data = hass_api.coerce_schema("script", qualified_action.replace("script.", ""), action_data) 

260 results.append(MiniEnvelope(action_data=action_data, target_data={ATTR_ENTITY_ID: target.domain_entity_ids("script")})) 

261 else: 

262 action_data = core_action_data 

263 action_data.update(data) 

264 results.append(MiniEnvelope(action_data=action_data)) 

265 

266 return results 

267 

268 

269def ntfy( 

270 core_action_data: dict[str, Any], 

271 data: dict[str, Any], 

272 target: Target, 

273 delivery: Delivery, 

274 hass_api: HomeAssistantAPI, 

275) -> list[MiniEnvelope]: 

276 """Customize `data` for ntfy integration""" 

277 results: list[MiniEnvelope] = [] 

278 action_data: dict[str, Any] = dict(core_action_data) 

279 action_data.update(data) 

280 action_data = hass_api.coerce_schema("ntfy", "publish", action_data) 

281 

282 if ATTR_PRIORITY in action_data and action_data[ATTR_PRIORITY] in PRIORITY_VALUES: 

283 action_data[ATTR_PRIORITY] = PRIORITY_VALUES.get(action_data[ATTR_PRIORITY], 3) 

284 

285 media = action_data.pop(ATTR_MEDIA, {}) 

286 if media and media.get(ATTR_MEDIA_SNAPSHOT_URL) and "attach" not in action_data: 

287 action_data["attach"] = media.get(ATTR_MEDIA_SNAPSHOT_URL) 

288 actions = action_data.pop(ATTR_ACTIONS, []) 

289 if len(actions) > 0: 

290 first_action = actions[0] 

291 if first_action.get(ATTR_ACTION_URL) and "click" not in action_data: 

292 action_data["click"] = first_action.get(ATTR_ACTION_URL) 

293 

294 if target.email and "email" not in action_data: 

295 for email in target.email: 

296 call_data: dict[str, Any] = dict(action_data) 

297 if len(results) == 1 and len(target.email) == 1: 

298 results[0].action_data["email"] = email 

299 else: 

300 call_data["email"] = email 

301 results.append(MiniEnvelope(action_data=call_data)) 

302 if target.phone and "call" not in action_data: 

303 for phone in target.phone: 

304 call_data = dict(action_data) 

305 if len(results) == 1 and len(target.phone) == 1: 

306 results[0].action_data["call"] = phone 

307 else: 

308 call_data["call"] = phone 

309 results.append(MiniEnvelope(action_data=call_data)) 

310 notify_entities = target.domain_entity_ids(NOTIFY_DOMAIN) 

311 

312 rules = delivery.options.get(OPTION_DATA_KEYS_SELECT) 

313 action_data = DataFilter(rules).apply(action_data) 

314 

315 if not results or notify_entities: 

316 if len(results) == 1: 

317 results[0].target_data = {"entity_id": notify_entities} 

318 else: 

319 results.append(MiniEnvelope(action_data=dict(action_data), target_data={"entity_id": notify_entities})) 

320 

321 return results 

322 

323 

324def notify_events( 

325 message: str | None, 

326 title: str | None, 

327 core_action_data: dict[str, Any], 

328 data: dict[str, Any], 

329 delivery: Delivery, 

330) -> list[MiniEnvelope]: 

331 """Customize `data` for notify_events integration""" 

332 results: list[MiniEnvelope] = [] 

333 input_data: dict[str, Any] = dict(core_action_data) 

334 input_data.update(data) 

335 

336 action_data: dict[str, Any] = {} 

337 action_data[ATTR_MESSAGE] = message 

338 priority_mapping: dict[str, str] = { 

339 PRIORITY_MINIMUM: "lowest", 

340 PRIORITY_LOW: "low", 

341 PRIORITY_MEDIUM: "normal", 

342 PRIORITY_HIGH: "high", 

343 PRIORITY_CRITICAL: "highest", 

344 } 

345 if title: 

346 action_data.setdefault(ATTR_DATA, {}) 

347 action_data[ATTR_DATA][ATTR_TITLE] = title 

348 

349 if ATTR_DATA in input_data: 

350 # notify_events is schema-less for action 

351 action_data[ATTR_DATA] = input_data[ATTR_DATA] 

352 

353 if ATTR_PRIORITY in input_data and input_data[ATTR_PRIORITY] in PRIORITY_VALUES: 

354 action_data.setdefault(ATTR_DATA, {}) 

355 action_data[ATTR_DATA][ATTR_PRIORITY] = priority_mapping.get(input_data[ATTR_PRIORITY]) 

356 elif ATTR_PRIORITY in input_data and input_data[ATTR_PRIORITY] in priority_mapping.values(): 

357 action_data.setdefault(ATTR_DATA, {}) 

358 action_data[ATTR_DATA][ATTR_PRIORITY] = input_data[ATTR_PRIORITY] 

359 

360 if "token" in input_data: 

361 action_data.setdefault(ATTR_DATA, {}) 

362 action_data[ATTR_DATA]["token"] = input_data["token"] 

363 if "level" in input_data: 

364 action_data.setdefault(ATTR_DATA, {}) 

365 action_data[ATTR_DATA]["level"] = input_data["level"] 

366 

367 if input_data.get(ATTR_MEDIA, {}).get(ATTR_MEDIA_SNAPSHOT_URL) and "images" not in input_data: 

368 action_data.setdefault(ATTR_DATA, {}) 

369 action_data[ATTR_DATA].setdefault("images", []) 

370 action_data[ATTR_DATA]["images"].append({"url": input_data.get(ATTR_MEDIA, {}).get(ATTR_MEDIA_SNAPSHOT_URL)}) 

371 

372 rules = delivery.options.get(OPTION_DATA_KEYS_SELECT) 

373 action_data = DataFilter(rules).apply(action_data) 

374 results.append(MiniEnvelope(action_data=dict(action_data))) 

375 

376 return results