Coverage for custom_components/supernotify/methods/chime.py: 93%
169 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-26 08:54 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-26 08:54 +0000
1import logging
2import re
3from typing import TYPE_CHECKING, Any
5from homeassistant.components.group import expand_entity_ids
6from homeassistant.components.notify.const import ATTR_MESSAGE, ATTR_TITLE
7from homeassistant.const import ( # ATTR_VARIABLES from script.const has import issues
8 ATTR_ENTITY_ID,
9 CONF_VARIABLES,
10)
12from custom_components.supernotify import (
13 ATTR_DATA,
14 ATTR_PRIORITY,
15 CONF_DATA,
16 CONF_DEVICE_DOMAIN,
17 CONF_TARGETS_REQUIRED,
18 METHOD_CHIME,
19)
20from custom_components.supernotify.common import ensure_list
21from custom_components.supernotify.delivery_method import DeliveryMethod
22from custom_components.supernotify.envelope import Envelope
24if TYPE_CHECKING:
25 from homeassistant.helpers.device_registry import DeviceEntry
27RE_VALID_CHIME = r"(switch|script|group|siren|media_player)\.[A-Za-z0-9_]+"
29_LOGGER = logging.getLogger(__name__)
31DATA_SCHEMA_RESTRICT: dict[str, list[str]] = {
32 "media_player": ["data", "entity_id", "media_content_id", "media_content_type", "enqueue", "announce"],
33 "switch": ["entity_id"],
34 "script": ["data", "variables", "context", "wait"],
35 "siren": ["data", "entity_id"],
36 "alexa_devices": ["sound", "device_id"],
37} # TODO: source directly from component schema
38DEVICE_DOMAINS = ["alexa_devices"]
41class ChimeTargetConfig:
42 def __init__(
43 self,
44 target: str,
45 tune: str | None = None,
46 duration: int | None = None,
47 volume: float | None = None,
48 data: dict[str, Any] | None = None,
49 domain: str | None = None,
50 **kwargs: Any,
51 ) -> None:
52 self.entity_id: str | None = None
53 self.device_id: str | None = None
54 self.domain: str | None = None
55 self.entity_name: str | None = None
56 if "." in target:
57 self.entity_id = target
58 self.domain, self.entity_name = target.split(".", 1)
59 else:
60 if self.is_device(target):
61 self.device_id = target
62 self.domain = domain
63 else:
64 raise ValueError(f"ChimeTargetConfig target must be entity_id or device_id: {target}")
65 if kwargs:
66 _LOGGER.warning("SUPERNOTIFY ChimeTargetConfig ignoring unexpected args: %s", kwargs)
67 self.volume: float | None = volume
68 self.tune: str | None = tune
69 self.duration: int | None = duration
70 self.data: dict[str, Any] | None = data or {}
72 def __repr__(self) -> str:
73 """Return a developer-oriented string representation of this ChimeTargetConfig"""
74 if self.device_id is not None:
75 return f"ChimeTargetConfig(device_id={self.device_id})"
76 return f"ChimeTargetConfig(entity_id={self.entity_id})"
78 @classmethod
79 def is_device(cls, target: str) -> bool:
80 return re.match(r"^[0-9a-f]{32}$", target) is not None
83class ChimeDeliveryMethod(DeliveryMethod):
84 method = METHOD_CHIME
86 def __init__(self, *args: Any, **kwargs: Any) -> None:
87 kwargs.setdefault(CONF_TARGETS_REQUIRED, False)
88 # support optional auto discovery
89 kwargs.setdefault(CONF_DEVICE_DOMAIN, DEVICE_DOMAINS)
90 super().__init__(*args, **kwargs)
92 @property
93 def chime_aliases(self) -> dict[str, Any]:
94 return self.default_options.get("chime_aliases") or {}
96 def validate_action(self, action: str | None) -> bool:
97 return action is None
99 def select_target(self, target: str) -> bool:
100 return re.fullmatch(RE_VALID_CHIME, target) is not None or ChimeTargetConfig.is_device(target)
102 async def deliver(self, envelope: Envelope) -> bool:
103 config = self.delivery_config(envelope.delivery_name)
104 data: dict[str, Any] = {}
105 data.update(config.get(CONF_DATA) or {})
106 data.update(envelope.data or {})
107 targets = envelope.targets or []
109 # chime_repeat = data.pop("chime_repeat", 1)
110 chime_tune: str | None = data.pop("chime_tune", None)
111 chime_volume: float | None = data.pop("chime_volume", None)
112 chime_duration: int | None = data.pop("chime_duration", None)
114 _LOGGER.info(
115 "SUPERNOTIFY notify_chime: %s -> %s (delivery: %s, env_data:%s, dlv_data:%s)",
116 chime_tune,
117 targets,
118 envelope.delivery_name,
119 envelope.data,
120 config.get(CONF_DATA),
121 )
122 # expand groups
123 expanded_targets = {
124 e: ChimeTargetConfig(tune=chime_tune, volume=chime_volume, duration=chime_duration, target=e)
125 for e in expand_entity_ids(self.hass, targets)
126 }
127 # resolve and include chime aliases
128 expanded_targets.update(self.resolve_tune(chime_tune)) # overwrite and extend
130 chimes = 0
131 for chime_entity_config in expanded_targets.values():
132 _LOGGER.debug("SUPERNOTIFY chime %s: %s", chime_entity_config.entity_id, chime_entity_config.tune)
133 action_data = None
134 try:
135 domain, service, action_data = self.analyze_target(chime_entity_config, data, envelope)
136 if domain is not None and service is not None:
137 action_data = self.prune_data(domain, action_data)
139 if await self.call_action(envelope, qualified_action=f"{domain}.{service}", action_data=action_data):
140 chimes += 1
141 else:
142 _LOGGER.debug("SUPERNOTIFY Chime skipping incomplete service for %s", chime_entity_config.entity_id)
143 except Exception:
144 _LOGGER.exception("SUPERNOTIFY Failed to chime %s: %s [%s]", chime_entity_config.entity_id, action_data)
145 return chimes > 0
147 def prune_data(self, domain: str, data: dict[str, Any]) -> dict[str, Any]:
148 pruned: dict[str, Any] = {}
149 if data and domain in DATA_SCHEMA_RESTRICT:
150 restrict: list[str] = DATA_SCHEMA_RESTRICT.get(domain) or []
151 for key in list(data.keys()):
152 if key in restrict:
153 pruned[key] = data[key]
154 return pruned
156 def analyze_target(
157 self, target_config: ChimeTargetConfig, data: dict[str, Any], envelope: Envelope
158 ) -> tuple[str | None, str | None, dict[str, Any]]:
159 if not target_config.entity_id and not target_config.device_id:
160 _LOGGER.warning("SUPERNOTIFY Empty chime target")
161 return "", None, {}
163 domain: str | None = None
164 name: str | None = None
166 # Alexa Devices use device_id not entity_id for sounds
167 if target_config.device_id is not None:
168 if target_config.domain is not None:
169 domain = target_config.domain
170 else:
171 # discover domain from device registry
172 device_registry = self.context.device_registry()
173 if device_registry:
174 device: DeviceEntry | None = device_registry.async_get(target_config.device_id)
175 if device and "alexa_devices" in [d for d, _id in device.identifiers]:
176 domain = "alexa_devices"
177 if domain is None:
178 _LOGGER.warning(
179 "SUPERNOTIFY A target that looks like a device_id can't be matched to supported integration: %s",
180 target_config.device_id,
181 )
182 elif target_config.entity_id and "." in target_config.entity_id:
183 domain, name = target_config.entity_id.split(".", 1)
185 action_data: dict[str, Any] = {}
186 action: str | None = None
188 if domain == "switch":
189 action = "turn_on"
190 action_data[ATTR_ENTITY_ID] = target_config.entity_id
192 elif domain == "siren":
193 action = "turn_on"
194 action_data[ATTR_ENTITY_ID] = target_config.entity_id
195 action_data[ATTR_DATA] = {}
196 if target_config.tune:
197 action_data[ATTR_DATA]["tone"] = target_config.tune
198 if target_config.duration is not None:
199 action_data[ATTR_DATA]["duration"] = target_config.duration
200 if target_config.volume is not None:
201 action_data[ATTR_DATA]["volume_level"] = target_config.volume
203 elif domain == "script":
204 action_data.setdefault(CONF_VARIABLES, {})
205 if target_config.data:
206 action_data[CONF_VARIABLES] = target_config.data.get(CONF_VARIABLES, {})
207 if data:
208 # override data sourced from chime alias with explicit variables in envelope/data
209 action_data[CONF_VARIABLES].update(data.get(CONF_VARIABLES, {}))
210 action = name
211 action_data[CONF_VARIABLES][ATTR_MESSAGE] = envelope.message
212 action_data[CONF_VARIABLES][ATTR_TITLE] = envelope.title
213 action_data[CONF_VARIABLES][ATTR_PRIORITY] = envelope.priority
214 action_data[CONF_VARIABLES]["chime_tune"] = target_config.tune
215 action_data[CONF_VARIABLES]["chime_volume"] = target_config.volume
216 action_data[CONF_VARIABLES]["chime_duration"] = target_config.duration
218 elif domain == "alexa_devices" and target_config.tune:
219 action = "send_sound"
220 action_data["device_id"] = target_config.device_id
221 action_data["sound"] = target_config.tune
223 elif domain == "media_player" and target_config.tune:
224 if target_config.data:
225 action_data.update(target_config.data)
226 if data:
227 action_data.update(data)
228 action = "play_media"
229 action_data[ATTR_ENTITY_ID] = target_config.entity_id
230 action_data["media_content_type"] = "sound"
231 action_data["media_content_id"] = target_config.tune
233 else:
234 _LOGGER.warning(
235 "SUPERNOTIFY No matching chime domain/tune: %s, target: %s, tune: %s",
236 domain,
237 target_config.entity_id,
238 target_config.tune,
239 )
241 return domain, action, action_data
243 def resolve_tune(self, tune_or_alias: str | None) -> dict[str, ChimeTargetConfig]:
244 target_configs: dict[str, ChimeTargetConfig] = {}
245 if tune_or_alias is not None:
246 for domain, alias_config in self.chime_aliases.get(tune_or_alias, {}).items():
247 if isinstance(alias_config, str):
248 tune = alias_config
249 alias_config = {}
250 else:
251 tune = alias_config.get("tune", tune_or_alias)
253 alias_config["tune"] = tune
254 alias_config.setdefault("domain", domain)
255 alias_config.setdefault("data", {})
256 target = alias_config.pop("target", None)
258 # pass through variables or data if present
259 if target is not None:
260 target_configs.update({t: ChimeTargetConfig(target=t, **alias_config) for t in ensure_list(target)}) # type: ignore
261 elif domain in DEVICE_DOMAINS:
262 # bulk apply to all known target devices of this domain
263 bulk_apply = {
264 dev: ChimeTargetConfig(target=dev, **alias_config) # type: ignore
265 for dev in self.targets
266 if ChimeTargetConfig.is_device(dev)
267 and dev not in target_configs # don't overwrite existing specific targets
268 }
269 target_configs.update(bulk_apply)
270 else:
271 # bulk apply to all known target entities of this domain
272 bulk_apply = {
273 ent: ChimeTargetConfig(target=ent, **alias_config) # type: ignore
274 for ent in self.targets
275 if ent.startswith(f"{alias_config['domain']}.")
276 and ent not in target_configs # don't overwrite existing specific targets
277 }
278 target_configs.update(bulk_apply)
279 _LOGGER.debug("SUPERNOTIFY method_chime: Resolved tune %s to %s", tune_or_alias, target_configs)
280 return target_configs