Coverage for custom_components / supernotify / transports / chime.py: 98%

240 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 abc import abstractmethod 

5from dataclasses import dataclass, field 

6from typing import TYPE_CHECKING, Any 

7 

8import voluptuous as vol 

9from homeassistant.components.notify.const import ATTR_MESSAGE, ATTR_TITLE 

10from homeassistant.const import ( # ATTR_VARIABLES from script.const has import issues 

11 ATTR_DEVICE_ID, 

12 ATTR_ENTITY_ID, 

13 CONF_DOMAIN, 

14 CONF_TARGET, 

15) 

16from homeassistant.exceptions import NoEntitySpecifiedError 

17from voluptuous.humanize import humanize_error 

18 

19from custom_components.supernotify.const import ( 

20 ATTR_DATA, 

21 ATTR_MEDIA, 

22 ATTR_PRIORITY, 

23 CONF_TUNE, 

24 OPTION_CHIME_ALIASES, 

25 OPTION_DEVICE_DISCOVERY, 

26 OPTION_DEVICE_DOMAIN, 

27 OPTION_DEVICE_MODEL_SELECT, 

28 OPTION_TARGET_CATEGORIES, 

29 OPTION_TARGET_SELECT, 

30 OPTIONS_CHIME_DOMAINS, 

31 RE_DEVICE_ID, 

32 SELECT_EXCLUDE, 

33 TRANSPORT_CHIME, 

34) 

35from custom_components.supernotify.model import ( 

36 DebugTrace, 

37 SelectionRule, 

38 Target, 

39 TargetRequired, 

40 TransportConfig, 

41 TransportFeature, 

42) 

43from custom_components.supernotify.schema import CHIME_ALIASES_SCHEMA 

44from custom_components.supernotify.transport import Transport 

45 

46if TYPE_CHECKING: 

47 from homeassistant.helpers.typing import ConfigType 

48 

49 from custom_components.supernotify.envelope import Envelope 

50 

51RE_VALID_CHIME = r"(switch|script|group|rest_command|siren|media_player)\.[A-Za-z0-9_]+" 

52 

53_LOGGER = logging.getLogger(__name__) 

54 

55DEVICE_DOMAINS = ["alexa_devices"] 

56 

57 

58@dataclass 

59class ActionCall: 

60 domain: str 

61 service: str 

62 action_data: dict[str, Any] | None = field(default_factory=dict) 

63 target_data: dict[str, Any] | None = field(default_factory=dict) 

64 

65 

66class ChimeTargetConfig: 

67 def __init__( 

68 self, 

69 entity_id: str | None = None, 

70 device_id: str | None = None, 

71 tune: str | None = None, 

72 duration: int | None = None, 

73 volume: float | None = None, 

74 data: dict[str, Any] | None = None, 

75 domain: str | None = None, 

76 **kwargs: Any, 

77 ) -> None: 

78 self.entity_id: str | None = entity_id 

79 self.device_id: str | None = device_id 

80 self.domain: str | None = None 

81 self.entity_name: str | None = None 

82 if self.entity_id and "." in self.entity_id: 

83 self.domain, self.entity_name = self.entity_id.split(".", 1) 

84 elif self.device_id: 

85 self.domain = domain 

86 else: 

87 _LOGGER.warning( 

88 "SUPERNOTIFY Invalid chime target, entity_id: %s, device_id %s, tune:%s", entity_id, device_id, tune 

89 ) 

90 raise NoEntitySpecifiedError("ChimeTargetConfig target must be entity_id or device_id") 

91 if kwargs: 

92 _LOGGER.warning("SUPERNOTIFY ChimeTargetConfig ignoring unexpected args: %s", kwargs) 

93 self.volume: float | None = volume 

94 self.tune: str | None = tune 

95 self.duration: int | None = duration 

96 self.data: dict[str, Any] | None = data or {} 

97 

98 def as_dict(self, **kwargs) -> dict[str, Any]: # noqa: ARG002 

99 return { 

100 "entity_id": self.entity_id, 

101 "device_id": self.device_id, 

102 "domain": self.domain, 

103 "tune": self.tune, 

104 "duration": self.duration, 

105 "volume": self.volume, 

106 "data": self.data, 

107 } 

108 

109 def __repr__(self) -> str: 

110 """Return a developer-oriented string representation of this ChimeTargetConfig""" 

111 if self.device_id is not None: 

112 return f"ChimeTargetConfig(device_id={self.device_id})" 

113 return f"ChimeTargetConfig(entity_id={self.entity_id})" 

114 

115 

116class MiniChimeTransport: 

117 domain: str 

118 

119 @abstractmethod 

120 def build( 

121 self, 

122 target_config: ChimeTargetConfig, 

123 action_data: dict[str, Any], 

124 entity_name: str | None = None, 

125 envelope: Envelope | None = None, 

126 **_kwargs: Any, 

127 ) -> ActionCall | None: 

128 raise NotImplementedError() 

129 

130 

131class RestCommandChimeTransport(MiniChimeTransport): 

132 domain = "rest_command" 

133 

134 def build( # type: ignore[override] 

135 self, target_config: ChimeTargetConfig, entity_name: str | None, **_kwargs: Any 

136 ) -> ActionCall | None: 

137 if entity_name is None: 

138 _LOGGER.warning("SUPERNOTIFY rest_command chime target requires entity") 

139 return None 

140 output_data = dict(target_config.data) if target_config.data else {} 

141 return ActionCall(self.domain, entity_name, action_data=output_data) 

142 

143 

144class SwitchChimeTransport(MiniChimeTransport): 

145 domain = "switch" 

146 

147 def build(self, target_config: ChimeTargetConfig, **_kwargs: Any) -> ActionCall | None: # type: ignore[override] 

148 return ActionCall(self.domain, "turn_on", target_data={ATTR_ENTITY_ID: target_config.entity_id}) 

149 

150 

151class SirenChimeTransport(MiniChimeTransport): 

152 domain = "siren" 

153 

154 def build(self, target_config: ChimeTargetConfig, **_kwargs: Any) -> ActionCall | None: # type: ignore[override] 

155 output_data: dict[str, Any] = {ATTR_DATA: {}} 

156 if target_config.tune: 

157 output_data[ATTR_DATA]["tone"] = target_config.tune 

158 if target_config.duration is not None: 

159 output_data[ATTR_DATA]["duration"] = target_config.duration 

160 if target_config.volume is not None: 

161 output_data[ATTR_DATA]["volume_level"] = target_config.volume 

162 return ActionCall( 

163 self.domain, "turn_on", action_data=output_data, target_data={ATTR_ENTITY_ID: target_config.entity_id} 

164 ) 

165 

166 

167class ScriptChimeTransport(MiniChimeTransport): 

168 domain = "script" 

169 

170 def build( # type: ignore[override] 

171 self, 

172 target_config: ChimeTargetConfig, 

173 entity_name: str | None, 

174 envelope: Envelope, 

175 **_kwargs: Any, 

176 ) -> ActionCall | None: 

177 if entity_name is None: 

178 _LOGGER.warning("SUPERNOTIFY script chime target requires entity") 

179 return None 

180 variables: dict[str, Any] = target_config.data or {} 

181 variables[ATTR_MESSAGE] = envelope.message 

182 variables[ATTR_TITLE] = envelope.title 

183 variables[ATTR_PRIORITY] = envelope.priority 

184 variables["chime_tune"] = target_config.tune 

185 variables["chime_volume"] = target_config.volume 

186 variables["chime_duration"] = target_config.duration 

187 output_data: dict[str, Any] = {"variables": variables} 

188 if envelope.delivery.debug: 

189 output_data["wait"] = envelope.delivery.debug 

190 # use `turn_on` rather than direct call to run script in background 

191 return ActionCall( 

192 self.domain, "turn_on", action_data=output_data, target_data={ATTR_ENTITY_ID: target_config.entity_id} 

193 ) 

194 

195 

196class AlexaDevicesChimeTransport(MiniChimeTransport): 

197 domain = "alexa_devices" 

198 

199 def build(self, target_config: ChimeTargetConfig, **_kwargs: Any) -> ActionCall | None: # type: ignore[override] 

200 output_data: dict[str, Any] = { 

201 "device_id": target_config.device_id, 

202 "sound": target_config.tune, 

203 } 

204 return ActionCall(self.domain, "send_sound", action_data=output_data) 

205 

206 

207class MediaPlayerChimeTransport(MiniChimeTransport): 

208 domain = "media_player" 

209 

210 def build(self, target_config: ChimeTargetConfig, action_data: dict[str, Any], **_kwargs: Any) -> ActionCall | None: # type: ignore[override] 

211 input_data = target_config.data or {} 

212 if action_data: 

213 input_data.update(action_data) 

214 output_data: dict[str, Any] = { 

215 "media": { 

216 "media_content_type": input_data.get(ATTR_MEDIA, {"media_content_type": "sound"}).get( 

217 "media_content_type", "sound" 

218 ), 

219 "media_content_id": target_config.tune, 

220 } 

221 } 

222 if input_data.get("enqueue") is not None: 

223 output_data["enqueue"] = input_data.get("enqueue") 

224 if input_data.get("announce") is not None: 

225 output_data["announce"] = input_data.get("announce") 

226 

227 return ActionCall( 

228 self.domain, "play_media", action_data=output_data, target_data={ATTR_ENTITY_ID: target_config.entity_id} 

229 ) 

230 

231 

232class ChimeTransport(Transport): 

233 name = TRANSPORT_CHIME 

234 

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

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

237 self.mini_transports: dict[str, MiniChimeTransport] = { 

238 t.domain: t 

239 for t in [ 

240 RestCommandChimeTransport(), 

241 SwitchChimeTransport(), 

242 SirenChimeTransport(), 

243 ScriptChimeTransport(), 

244 AlexaDevicesChimeTransport(), 

245 MediaPlayerChimeTransport(), 

246 ] 

247 } 

248 

249 def setup_delivery_options(self, options: dict[str, Any], delivery_name: str) -> dict[str, Any]: 

250 if OPTION_CHIME_ALIASES in options: 

251 chime_aliases: ConfigType = build_aliases(options[OPTION_CHIME_ALIASES]) 

252 if chime_aliases: 

253 _LOGGER.info("SUPERNOTIFY Set up %s chime aliases for %s", len(chime_aliases), delivery_name) 

254 else: 

255 _LOGGER.warning("SUPERNOTIFY Chime aliases for %s configured but not recognized", delivery_name) 

256 else: 

257 chime_aliases = {} 

258 _LOGGER.debug("SUPERNOTIFY No chime aliases configured for %s", delivery_name) 

259 return {"chime_aliases": chime_aliases} 

260 

261 @property 

262 def supported_features(self) -> TransportFeature: 

263 return TransportFeature(0) 

264 

265 def extra_attributes(self) -> dict[str, Any]: 

266 return {"mini_transports": [t.domain for t in self.mini_transports.values()]} 

267 

268 @property 

269 def default_config(self) -> TransportConfig: 

270 config = TransportConfig() 

271 config.delivery_defaults.target_required = TargetRequired.OPTIONAL 

272 config.delivery_defaults.options = { 

273 OPTION_TARGET_CATEGORIES: [ATTR_ENTITY_ID, ATTR_DEVICE_ID], 

274 OPTION_TARGET_SELECT: [RE_VALID_CHIME, RE_DEVICE_ID], 

275 OPTION_DEVICE_DISCOVERY: True, 

276 OPTION_DEVICE_DOMAIN: DEVICE_DOMAINS, 

277 OPTION_DEVICE_MODEL_SELECT: {SELECT_EXCLUDE: ["Speaker Group"]}, 

278 } 

279 return config 

280 

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

282 return action is None 

283 

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

285 data: dict[str, Any] = {} 

286 data.update(envelope.delivery.data) 

287 data.update(envelope.data or {}) 

288 target: Target = envelope.target 

289 

290 # chime_repeat = data.pop("chime_repeat", 1) 

291 chime_tune: str | None = data.pop("chime_tune", None) 

292 chime_volume: float | None = data.pop("chime_volume", None) 

293 chime_duration: int | None = data.pop("chime_duration", None) 

294 

295 _LOGGER.debug( 

296 "SUPERNOTIFY notify_chime: %s -> %s (delivery: %s, env_data:%s, dlv_data:%s)", 

297 chime_tune, 

298 target.entity_ids, 

299 envelope.delivery_name, 

300 envelope.data, 

301 envelope.delivery.data, 

302 ) 

303 # expand groups 

304 expanded_targets = { 

305 e: ChimeTargetConfig(tune=chime_tune, volume=chime_volume, duration=chime_duration, entity_id=e) 

306 for e in self.hass_api.expand_group(target.entity_ids) 

307 } 

308 model_filter = SelectionRule(envelope.delivery.options.get(OPTION_DEVICE_MODEL_SELECT)) 

309 dev_reg = self.hass_api.device_registry() 

310 expanded_targets.update({ 

311 d: ChimeTargetConfig(tune=chime_tune, volume=chime_volume, duration=chime_duration, device_id=d) 

312 for d in target.device_ids 

313 if not dev_reg 

314 or not (dev_entry := dev_reg.async_get(d)) 

315 or model_filter.match(dev_entry.model if isinstance(dev_entry.model, str) else None) 

316 }) 

317 # resolve and include chime aliases 

318 expanded_targets.update( 

319 self.resolve_tune(chime_tune, envelope.delivery.transport_data.get("chime_aliases", {}), target) 

320 ) # overwrite and extend 

321 

322 chimes = 0 

323 if not expanded_targets: 

324 _LOGGER.info("SUPERNOTIFY skipping chime, no targets") 

325 return False 

326 if debug_trace: 

327 debug_trace.record_delivery_artefact(envelope.delivery.name, "expanded_targets", expanded_targets) 

328 

329 for chime_entity_config in expanded_targets.values(): 

330 _LOGGER.debug("SUPERNOTIFY chime %s: %s", chime_entity_config.entity_id, chime_entity_config.tune) 

331 try: 

332 action_call: ActionCall | None = self.analyze_target(chime_entity_config, data, envelope) 

333 if action_call is not None: 

334 if await self.call_action( 

335 envelope, 

336 qualified_action=f"{action_call.domain}.{action_call.service}", 

337 action_data=action_call.action_data, 

338 target_data=action_call.target_data, 

339 ): 

340 chimes += 1 

341 else: 

342 _LOGGER.debug("SUPERNOTIFY Chime skipping incomplete service for %s", chime_entity_config.entity_id) 

343 except Exception as e: 

344 _LOGGER.error( 

345 "SUPERNOTIFY Failed to chime %s: %s", 

346 chime_entity_config.entity_id, 

347 e, 

348 ) 

349 if debug_trace: 

350 debug_trace.record_delivery_exception(envelope.delivery.name, "chime_target", e) 

351 return chimes > 0 

352 

353 def analyze_target(self, target_config: ChimeTargetConfig, data: dict[str, Any], envelope: Envelope) -> ActionCall | None: 

354 

355 if not target_config.entity_id and not target_config.device_id: 

356 _LOGGER.warning("SUPERNOTIFY Empty chime target") 

357 return None 

358 

359 domain: str | None = None 

360 name: str | None = None 

361 

362 # Alexa Devices use device_id not entity_id for sounds 

363 # TODO: use method or delivery config vs fixed local constant for domains 

364 if target_config.device_id is not None and DEVICE_DOMAINS: 

365 if target_config.domain is not None and target_config.domain in DEVICE_DOMAINS: 

366 _LOGGER.debug(f"SUPERNOTIFY Chime selected target {domain} for {target_config.domain}") 

367 domain = target_config.domain 

368 else: 

369 domain = self.hass_api.domain_for_device(target_config.device_id, DEVICE_DOMAINS) 

370 _LOGGER.debug(f"SUPERNOTIFY Chime selected device {domain} for {target_config.device_id}") 

371 

372 elif target_config.entity_id and "." in target_config.entity_id: 

373 domain, name = target_config.entity_id.split(".", 1) 

374 if not domain: 

375 _LOGGER.warning("SUPERNOTIFY Unknown domain: %s", target_config) 

376 return None 

377 mini_transport: MiniChimeTransport | None = self.mini_transports.get(domain) 

378 if mini_transport is None: 

379 _LOGGER.warning( 

380 "SUPERNOTIFY No matching chime domain/tune: %s, target: %s, tune: %s", 

381 domain, 

382 target_config.entity_id, 

383 target_config.tune, 

384 ) 

385 return None 

386 

387 action_call: ActionCall | None = mini_transport.build( 

388 envelope=envelope, entity_name=name, action_data=data, target_config=target_config 

389 ) 

390 _LOGGER.debug("SUPERNOTIFY analyze_chime->%s", action_call) 

391 

392 return action_call 

393 

394 def resolve_tune( 

395 self, tune_or_alias: str | None, chime_config: dict[str, Any], target: Target | None = None 

396 ) -> dict[str, ChimeTargetConfig]: 

397 target_configs: dict[str, ChimeTargetConfig] = {} 

398 if tune_or_alias is not None: 

399 for alias_config in chime_config.get(tune_or_alias, {}).values(): 

400 alias_target: Target | None = alias_config.get(CONF_TARGET, None) 

401 alias_kwargs: dict[str, Any] = {k: v for k, v in alias_config.items() if k != CONF_TARGET} 

402 # pass through variables or data if present 

403 if alias_target is not None: 

404 target_configs.update({t: ChimeTargetConfig(entity_id=t, **alias_kwargs) for t in alias_target.entity_ids}) 

405 target_configs.update({t: ChimeTargetConfig(device_id=t, **alias_kwargs) for t in alias_target.device_ids}) 

406 elif alias_config[CONF_DOMAIN] in DEVICE_DOMAINS and target is not None: 

407 # bulk apply to all known target devices of this domain 

408 bulk_apply = { 

409 dev: ChimeTargetConfig(device_id=dev, **alias_kwargs) 

410 for dev in target.device_ids 

411 if dev not in target_configs # don't overwrite existing specific targets 

412 and ATTR_DEVICE_ID not in alias_config 

413 } 

414 # TODO: Constrain to device domain 

415 target_configs.update(bulk_apply) 

416 elif target is not None: 

417 # bulk apply to all known target entities of this domain 

418 bulk_apply = { 

419 ent: ChimeTargetConfig(entity_id=ent, **alias_kwargs) 

420 for ent in target.entity_ids 

421 if ent.startswith(f"{alias_config[CONF_DOMAIN]}.") 

422 and ent not in target_configs # don't overwrite existing specific targets 

423 and ATTR_ENTITY_ID not in alias_config 

424 } 

425 target_configs.update(bulk_apply) 

426 _LOGGER.debug("SUPERNOTIFY transport_chime: Resolved tune %s to %s", tune_or_alias, target_configs) 

427 return target_configs 

428 

429 

430def build_aliases(src_config: ConfigType) -> ConfigType: 

431 dest_config: dict[str, Any] = {} 

432 try: 

433 validated: ConfigType = CHIME_ALIASES_SCHEMA({OPTION_CHIME_ALIASES: src_config}) 

434 for alias, alias_config in validated[OPTION_CHIME_ALIASES].items(): 

435 alias_config = alias_config or {} 

436 for domain_or_label, domain_config in alias_config.items(): 

437 domain_config = domain_config or {} 

438 if isinstance(domain_config, str): 

439 domain_config = {CONF_TUNE: domain_config} 

440 domain_config.setdefault(CONF_TUNE, alias) 

441 if domain_or_label in OPTIONS_CHIME_DOMAINS: 

442 domain_config.setdefault(CONF_DOMAIN, domain_or_label) 

443 

444 try: 

445 if domain_config.get(CONF_TARGET): 

446 domain_config[CONF_TARGET] = Target(domain_config[CONF_TARGET]) 

447 if not domain_config[CONF_TARGET].has_targets(): 

448 _LOGGER.warning("SUPERNOTIFY chime alias %s has empty target", alias) 

449 elif domain_config[CONF_TARGET].has_unknown_targets(): 

450 _LOGGER.warning("SUPERNOTIFY chime alias %s has unknown targets", alias) 

451 dest_config.setdefault(alias, {}) 

452 dest_config[alias][domain_or_label] = domain_config 

453 except Exception as e: 

454 _LOGGER.exception("SUPERNOTIFY chime alias %s has invalid target: %s", alias, e) 

455 

456 except vol.Invalid as ve: 

457 _LOGGER.error("SUPERNOTIFY Chime alias configuration error: %s", ve) 

458 _LOGGER.error("SUPERNOTIFY %s", humanize_error(src_config, ve)) 

459 except Exception as e: 

460 _LOGGER.exception("SUPERNOTIFY Chime alias unexpected error: %s", e) 

461 return dest_config