Coverage for custom_components/supernotify/scenario.py: 23%
158 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-02-06 15:56 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-02-06 15:56 +0000
1import logging
2import re
3from typing import TYPE_CHECKING, Any
5from homeassistant.const import CONF_ENABLED
6from homeassistant.helpers import issue_registry as ir
8from .const import ATTR_MEDIA
9from .hass_api import HomeAssistantAPI
10from .model import DeliveryCustomization
12if TYPE_CHECKING:
13 from homeassistant.core import HomeAssistant
14 from homeassistant.helpers.typing import ConfigType
16 from .schema import ConditionsFunc
18from collections.abc import Iterator
19from contextlib import contextmanager
21import voluptuous as vol
23# type: ignore[attr-defined,unused-ignore]
24from homeassistant.components.trace import async_store_trace
25from homeassistant.components.trace.models import ActionTrace
26from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_NAME, CONF_ALIAS, CONF_CONDITION, CONF_CONDITIONS
27from homeassistant.core import Context, HomeAssistant
28from homeassistant.helpers.typing import ConfigType
30from .const import ATTR_ENABLED, CONF_ACTION_GROUP_NAMES, CONF_DELIVERY, CONF_MEDIA
31from .delivery import Delivery, DeliveryRegistry
32from .model import ConditionVariables
34_LOGGER = logging.getLogger(__name__)
37class ScenarioRegistry:
38 def __init__(self, scenario_configs: ConfigType) -> None:
39 self._config: ConfigType = scenario_configs or {}
40 self.scenarios: dict[str, Scenario] = {}
42 async def initialize(
43 self,
44 delivery_registry: DeliveryRegistry,
45 mobile_actions: ConfigType,
46 hass_api: HomeAssistantAPI,
47 ) -> None:
49 for scenario_name, scenario_definition in self._config.items():
50 scenario = Scenario(scenario_name, scenario_definition, delivery_registry, hass_api)
51 if await scenario.validate(valid_action_group_names=list(mobile_actions)):
52 self.scenarios[scenario_name] = scenario
53 else:
54 _LOGGER.warning("SUPERNOTIFY Scenario %s failed to validate, ignoring", scenario.name)
57class Scenario:
58 def __init__(
59 self, name: str, scenario_definition: dict[str, Any], delivery_registry: DeliveryRegistry, hass_api: HomeAssistantAPI
60 ) -> None:
61 self.hass_api: HomeAssistantAPI = hass_api
62 self.delivery_registry = delivery_registry
63 self.enabled: bool = scenario_definition.get(CONF_ENABLED, True)
64 self.name: str = name
65 self.alias: str | None = scenario_definition.get(CONF_ALIAS)
66 self.conditions: ConditionsFunc | None = None
67 self.conditions_config: list[ConfigType] | None = scenario_definition.get(CONF_CONDITIONS)
68 if not scenario_definition.get(CONF_CONDITIONS) and scenario_definition.get(CONF_CONDITION):
69 self.conditions_config = scenario_definition.get(CONF_CONDITION)
70 self.media: dict[str, Any] | None = scenario_definition.get(CONF_MEDIA)
71 self.action_groups: list[str] = scenario_definition.get(CONF_ACTION_GROUP_NAMES, [])
72 self._config_delivery: dict[str, DeliveryCustomization]
73 self.delivery_overrides: dict[str, DeliveryCustomization] = {}
74 self._delivery_selector: dict[str, str] = {}
75 self.last_trace: ActionTrace | None = None
76 self.startup_issue_count: int = 0
78 delivery_data = scenario_definition.get(CONF_DELIVERY)
79 if isinstance(delivery_data, list):
80 # a bare list of deliveries implies enabling
81 _LOGGER.debug("SUPERNOTIFY scenario %s delivery default enabled for list %s", self.name, delivery_data)
82 self._config_delivery = {k: DeliveryCustomization({CONF_ENABLED: True}) for k in delivery_data}
83 elif isinstance(delivery_data, str) and delivery_data:
84 # a bare list of deliveries implies enabled delivery
85 _LOGGER.debug("SUPERNOTIFY scenario %s delivery default enabled for single %s", self.name, delivery_data)
86 self._config_delivery = {delivery_data: DeliveryCustomization({CONF_ENABLED: True})}
87 elif isinstance(delivery_data, dict):
88 # whereas a dict may be used to tune or restrict
89 _LOGGER.debug("SUPERNOTIFY scenario %s delivery selection %s", self.name, delivery_data)
90 self._config_delivery = {k: DeliveryCustomization(v) for k, v in delivery_data.items()}
91 elif delivery_data:
92 _LOGGER.warning("SUPERNOTIFY Unable to interpret scenario %s delivery data %s", self.name, delivery_data)
93 self._config_delivery = {}
94 else:
95 _LOGGER.warning("SUPERNOTIFY No delivery definitions for scenario %s", self.name)
96 self._config_delivery = {}
98 async def validate(self, valid_action_group_names: list[str] | None = None) -> bool:
99 """Validate Home Assistant conditiion definition at initiation"""
100 if self.conditions_config:
101 error: str | None = None
102 try:
103 # note: basic template syntax within conditions already validated by voluptuous checks
104 self.conditions = await self.hass_api.build_conditions(self.conditions_config, strict=True, validate=True)
105 except vol.Invalid as vi:
106 _LOGGER.error(
107 f"SUPERNOTIFY Condition definition for scenario {self.name} fails Home Assistant schema check {vi}"
108 )
109 error = f"Schema error {vi}"
110 except Exception as e:
111 _LOGGER.error(
112 "SUPERNOTIFY Disabling scenario %s with error validating %s: %s", self.name, self.conditions_config, e
113 )
114 error = f"Unknown error {e}"
115 if error is not None:
116 self.startup_issue_count += 1
117 self.hass_api.raise_issue(
118 f"scenario_{self.name}_condition",
119 is_fixable=False,
120 issue_key="scenario_condition",
121 issue_map={"scenario": self.name, "error": error},
122 severity=ir.IssueSeverity.ERROR,
123 learn_more_url="https://supernotify.rhizomatics.org.uk/scenarios/",
124 )
126 for name_or_pattern, config in self._config_delivery.items():
127 matched: bool = False
128 delivery: Delivery | None = self.delivery_registry.deliveries.get(name_or_pattern)
129 if delivery:
130 self.delivery_overrides[delivery.name] = config
131 self._delivery_selector[delivery.name] = name_or_pattern
132 matched = True
133 else:
134 # look for a wildcard match instead
135 for delivery_name in self.delivery_registry.deliveries:
136 if re.fullmatch(name_or_pattern, delivery_name):
137 if self._delivery_selector.get(delivery_name) == delivery_name:
138 _LOGGER.info(
139 f"SUPERNOTIFY Scenario {self.name} ignoring '{name_or_pattern}' shadowing explicit delivery {delivery_name}" # noqa: E501
140 )
141 else:
142 _LOGGER.debug(
143 f"SUPERNOTIFY Scenario {self.name} delivery '{name_or_pattern}' matched {delivery_name}"
144 )
145 self.delivery_overrides[delivery_name] = config
146 self._delivery_selector[delivery_name] = name_or_pattern
147 matched = True
148 if not matched:
149 _LOGGER.error(f"SUPERNOTIFY Scenario {self.name} has delivery {name_or_pattern} not found")
150 self.startup_issue_count += 1
151 self.hass_api.raise_issue(
152 f"scenario_{self.name}_delivery_{name_or_pattern.replace('.', 'DOT').replace('*', 'STAR')}",
153 is_fixable=False,
154 issue_key="scenario_delivery",
155 issue_map={"scenario": self.name, "delivery": name_or_pattern},
156 severity=ir.IssueSeverity.WARNING,
157 learn_more_url="https://supernotify.rhizomatics.org.uk/scenarios/",
158 )
160 if valid_action_group_names is not None:
161 invalid_action_groups: list[str] = []
162 for action_group_name in self.action_groups:
163 if action_group_name not in valid_action_group_names:
164 _LOGGER.error(f"SUPERNOTIFY Unknown action group {action_group_name} removed from scenario {self.name}")
165 invalid_action_groups.append(action_group_name)
166 self.startup_issue_count += 1
167 self.hass_api.raise_issue(
168 f"scenario_{self.name}_action_group_{action_group_name}",
169 is_fixable=False,
170 issue_key="scenario_delivery",
171 issue_map={"scenario": self.name, "action_group": action_group_name},
172 severity=ir.IssueSeverity.WARNING,
173 learn_more_url="https://supernotify.rhizomatics.org.uk/scenarios/",
174 )
175 for action_group_name in invalid_action_groups:
176 self.action_groups.remove(action_group_name)
178 return self.startup_issue_count == 0
180 def enabling_deliveries(self) -> list[str]:
181 return [del_name for del_name, del_config in self.delivery_overrides.items() if del_config.enabled]
183 def relevant_deliveries(self) -> list[str]:
184 return [
185 del_name
186 for del_name, del_config in self.delivery_overrides.items()
187 if del_config.enabled or del_config.enabled is None
188 ]
190 def disabling_deliveries(self) -> list[str]:
191 return [del_name for del_name, del_config in self.delivery_overrides.items() if del_config.enabled is False]
193 def delivery_customization(self, delivery_name: str) -> DeliveryCustomization | None:
194 return self.delivery_overrides.get(delivery_name)
196 def attributes(self, include_condition: bool = True, include_trace: bool = False) -> dict[str, Any]:
197 """Return scenario attributes"""
198 attrs = {
199 ATTR_NAME: self.name,
200 ATTR_ENABLED: self.enabled,
201 ATTR_MEDIA: self.media,
202 "action_groups": self.action_groups,
203 "delivery": self.delivery_overrides,
204 }
205 if self.alias:
206 attrs[ATTR_FRIENDLY_NAME] = self.alias
207 if include_condition:
208 attrs["conditions"] = self.conditions_config
209 if include_trace and self.last_trace:
210 attrs["trace"] = self.last_trace.as_extended_dict()
211 return attrs
213 def delivery_config(self, delivery_name: str) -> DeliveryCustomization | None:
214 return self.delivery_overrides.get(delivery_name)
216 def contents(self, minimal: bool = False, **_kwargs: Any) -> dict[str, Any]:
217 """Archive friendly view of scenario"""
218 return self.attributes(include_condition=False, include_trace=not minimal)
220 def evaluate(self, condition_variables: ConditionVariables) -> bool:
221 """Evaluate scenario conditions"""
222 result: bool | None = False
223 if self.enabled and self.conditions:
224 try:
225 result = self.hass_api.evaluate_conditions(self.conditions, condition_variables)
226 if result is None:
227 _LOGGER.warning(f"SUPERNOTIFY Scenario {self.name} condition empty result")
228 except Exception as e:
229 _LOGGER.error(
230 f"SUPERNOTIFY Scenario {self.name} condition eval failed: %s, vars: %s",
231 e,
232 condition_variables.as_dict() if condition_variables else {},
233 )
234 return result if result is not None else False
236 async def trace(self, condition_variables: ConditionVariables) -> bool:
237 """Trace scenario condition execution"""
238 result: bool | None = False
239 trace: ActionTrace | None = None
240 if self.enabled and self.conditions:
241 result, trace = await self.hass_api.trace_conditions(
242 self.conditions, condition_variables, trace_name=f"scenario_{self.name}"
243 )
244 if trace:
245 self.last_trace = trace
246 return result if result is not None else False
249@contextmanager
250def trace_action(
251 hass: HomeAssistant,
252 item_id: str,
253 config: dict[str, Any],
254 context: Context | None = None,
255 stored_traces: int = 5,
256) -> Iterator[ActionTrace]:
257 """Trace execution of a scenario."""
258 trace = ActionTrace(item_id, config, None, context or Context())
259 async_store_trace(hass, trace, stored_traces)
261 try:
262 yield trace
263 except Exception as ex:
264 if item_id:
265 trace.set_error(ex)
266 raise
267 finally:
268 if item_id:
269 trace.finished()