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