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

242 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-01-07 15:35 +0000

1import logging 

2from abc import abstractmethod 

3from dataclasses import dataclass, field 

4from typing import Any 

5 

6import voluptuous as vol 

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

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

9 ATTR_DEVICE_ID, 

10 ATTR_ENTITY_ID, 

11 CONF_DOMAIN, 

12 CONF_TARGET, 

13) 

14from homeassistant.exceptions import NoEntitySpecifiedError 

15from homeassistant.helpers.typing import ConfigType 

16from voluptuous.humanize import humanize_error 

17 

18from custom_components.supernotify import ( 

19 ATTR_DATA, 

20 ATTR_MEDIA, 

21 ATTR_PRIORITY, 

22 CHIME_ALIASES_SCHEMA, 

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.envelope import Envelope 

36from custom_components.supernotify.model import DebugTrace, Target, TargetRequired, TransportConfig, TransportFeature 

37from custom_components.supernotify.transport import Transport 

38 

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

40 

41_LOGGER = logging.getLogger(__name__) 

42 

43DEVICE_DOMAINS = ["alexa_devices"] 

44 

45 

46@dataclass 

47class ActionCall: 

48 domain: str 

49 service: str 

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

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

52 

53 

54class ChimeTargetConfig: 

55 def __init__( 

56 self, 

57 entity_id: str | None = None, 

58 device_id: str | None = None, 

59 tune: str | None = None, 

60 duration: int | None = None, 

61 volume: float | None = None, 

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

63 domain: str | None = None, 

64 **kwargs: Any, 

65 ) -> None: 

66 self.entity_id: str | None = entity_id 

67 self.device_id: str | None = device_id 

68 self.domain: str | None = None 

69 self.entity_name: str | None = None 

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

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

72 elif self.device_id: 

73 self.domain = domain 

74 else: 

75 _LOGGER.warning( 

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

77 ) 

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

79 if kwargs: 

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

81 self.volume: float | None = volume 

82 self.tune: str | None = tune 

83 self.duration: int | None = duration 

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

85 

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

87 return { 

88 "entity_id": self.entity_id, 

89 "device_id": self.device_id, 

90 "domain": self.domain, 

91 "tune": self.tune, 

92 "duration": self.duration, 

93 "volume": self.volume, 

94 "data": self.data, 

95 } 

96 

97 def __repr__(self) -> str: 

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

99 if self.device_id is not None: 

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

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

102 

103 

104class MiniChimeTransport: 

105 domain: str 

106 

107 @abstractmethod 

108 def build( 

109 self, 

110 target_config: ChimeTargetConfig, 

111 action_data: dict[str, Any], 

112 entity_name: str | None = None, 

113 envelope: Envelope | None = None, 

114 **_kwargs: Any, 

115 ) -> ActionCall | None: 

116 raise NotImplementedError() 

117 

118 

119class RestCommandChimeTransport(MiniChimeTransport): 

120 domain = "rest_command" 

121 

122 def build( # type: ignore[override] 

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

124 ) -> ActionCall | None: 

125 if entity_name is None: 

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

127 return None 

128 output_data = target_config.data or {} 

129 if target_config.data: 

130 output_data.update(target_config.data) 

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

132 

133 

134class SwitchChimeTransport(MiniChimeTransport): 

135 domain = "switch" 

136 

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

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

139 

140 

141class SirenChimeTransport(MiniChimeTransport): 

142 domain = "siren" 

143 

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

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

146 if target_config.tune: 

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

148 if target_config.duration is not None: 

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

150 if target_config.volume is not None: 

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

152 return ActionCall( 

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

154 ) 

155 

156 

157class ScriptChimeTransport(MiniChimeTransport): 

158 domain = "script" 

159 

160 def build( # type: ignore[override] 

161 self, 

162 target_config: ChimeTargetConfig, 

163 entity_name: str | None, 

164 envelope: Envelope, 

165 **_kwargs: Any, 

166 ) -> ActionCall | None: 

167 if entity_name is None: 

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

169 return None 

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

171 variables[ATTR_MESSAGE] = envelope.message 

172 variables[ATTR_TITLE] = envelope.title 

173 variables[ATTR_PRIORITY] = envelope.priority 

174 variables["chime_tune"] = target_config.tune 

175 variables["chime_volume"] = target_config.volume 

176 variables["chime_duration"] = target_config.duration 

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

178 if envelope.delivery.debug: 

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

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

181 return ActionCall( 

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

183 ) 

184 

185 

186class AlexaDevicesChimeTransport(MiniChimeTransport): 

187 domain = "alexa_devices" 

188 

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

190 output_data: dict[str, Any] = { 

191 "device_id": target_config.device_id, 

192 "sound": target_config.tune, 

193 } 

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

195 

196 

197class MediaPlayerChimeTransport(MiniChimeTransport): 

198 domain = "media_player" 

199 

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

201 input_data = target_config.data or {} 

202 if action_data: 

203 input_data.update(action_data) 

204 output_data: dict[str, Any] = { 

205 "media": { 

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

207 "media_content_type", "sound" 

208 ), 

209 "media_content_id": target_config.tune, 

210 } 

211 } 

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

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

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

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

216 

217 return ActionCall( 

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

219 ) 

220 

221 

222class ChimeTransport(Transport): 

223 name = TRANSPORT_CHIME 

224 

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

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

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

228 t.domain: t 

229 for t in [ 

230 RestCommandChimeTransport(), 

231 SwitchChimeTransport(), 

232 SirenChimeTransport(), 

233 ScriptChimeTransport(), 

234 AlexaDevicesChimeTransport(), 

235 MediaPlayerChimeTransport(), 

236 ] 

237 } 

238 

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

240 if OPTION_CHIME_ALIASES in options: 

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

242 if chime_aliases: 

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

244 else: 

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

246 else: 

247 chime_aliases = {} 

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

249 return {"chime_aliases": chime_aliases} 

250 

251 @property 

252 def supported_features(self) -> TransportFeature: 

253 return TransportFeature(0) 

254 

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

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

257 

258 @property 

259 def default_config(self) -> TransportConfig: 

260 config = TransportConfig() 

261 config.delivery_defaults.options = {} 

262 config.delivery_defaults.target_required = TargetRequired.OPTIONAL 

263 config.delivery_defaults.options = { 

264 OPTION_TARGET_CATEGORIES: [ATTR_ENTITY_ID, ATTR_DEVICE_ID], 

265 OPTION_TARGET_SELECT: [RE_VALID_CHIME, RE_DEVICE_ID], 

266 OPTION_DEVICE_DISCOVERY: True, 

267 OPTION_DEVICE_DOMAIN: DEVICE_DOMAINS, 

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

269 } 

270 return config 

271 

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

273 return action is None 

274 

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

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

277 data.update(envelope.delivery.data) 

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

279 target: Target = envelope.target 

280 

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

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

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

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

285 

286 _LOGGER.debug( 

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

288 chime_tune, 

289 target.entity_ids, 

290 envelope.delivery_name, 

291 envelope.data, 

292 envelope.delivery.data, 

293 ) 

294 # expand groups 

295 expanded_targets = { 

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

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

298 } 

299 expanded_targets.update({ 

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

301 for d in target.device_ids 

302 }) 

303 # resolve and include chime aliases 

304 expanded_targets.update( 

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

306 ) # overwrite and extend 

307 

308 chimes = 0 

309 if not expanded_targets: 

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

311 return False 

312 if debug_trace: 

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

314 

315 for chime_entity_config in expanded_targets.values(): 

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

317 action_data: dict[str, Any] | None = None 

318 try: 

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

320 if action_call is not None: 

321 if await self.call_action( 

322 envelope, 

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

324 action_data=action_call.action_data, 

325 target_data=action_call.target_data, 

326 ): 

327 chimes += 1 

328 else: 

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

330 except Exception as e: 

331 _LOGGER.error("SUPERNOTIFY Failed to chime %s: %s [%s]", chime_entity_config.entity_id, action_data) 

332 if debug_trace: 

333 debug_trace.record_delivery_exception(envelope.delivery.name, "analyze_target", e) 

334 return chimes > 0 

335 

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

337 

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

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

340 return None 

341 

342 domain: str | None = None 

343 name: str | None = None 

344 

345 # Alexa Devices use device_id not entity_id for sounds 

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

347 if target_config.device_id is not None and DEVICE_DOMAINS: 

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

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

350 domain = target_config.domain 

351 else: 

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

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

354 

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

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

357 if not domain: 

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

359 return None 

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

361 if mini_transport is None: 

362 _LOGGER.warning( 

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

364 domain, 

365 target_config.entity_id, 

366 target_config.tune, 

367 ) 

368 return None 

369 

370 action_call: ActionCall | None = mini_transport.build( 

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

372 ) 

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

374 

375 return action_call 

376 

377 def resolve_tune( 

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

379 ) -> dict[str, ChimeTargetConfig]: 

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

381 if tune_or_alias is not None: 

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

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

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

385 # pass through variables or data if present 

386 if alias_target is not None: 

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

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

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

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

391 bulk_apply = { 

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

393 for dev in target.device_ids 

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

395 and ATTR_DEVICE_ID not in alias_config 

396 } 

397 # TODO: Constrain to device domain 

398 target_configs.update(bulk_apply) 

399 elif target is not None: 

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

401 bulk_apply = { 

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

403 for ent in target.entity_ids 

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

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

406 and ATTR_ENTITY_ID not in alias_config 

407 } 

408 target_configs.update(bulk_apply) 

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

410 return target_configs 

411 

412 

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

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

415 try: 

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

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

418 alias_config = alias_config or {} 

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

420 domain_config = domain_config or {} 

421 if isinstance(domain_config, str): 

422 domain_config = {CONF_TUNE: domain_config} 

423 domain_config.setdefault(CONF_TUNE, alias) 

424 if domain_or_label in OPTIONS_CHIME_DOMAINS: 

425 domain_config.setdefault(CONF_DOMAIN, domain_or_label) 

426 

427 try: 

428 if domain_config.get(CONF_TARGET): 

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

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

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

432 elif domain_config[CONF_TARGET].has_unknown_targets(): 

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

434 dest_config.setdefault(alias, {}) 

435 dest_config[alias][domain_or_label] = domain_config 

436 except Exception as e: 

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

438 

439 except vol.Invalid as ve: 

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

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

442 except Exception as e: 

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

444 return dest_config