Coverage for custom_components/supernotify/scenario.py: 90%

155 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-01 18:25 +0000

1from __future__ import annotations 

2 

3import logging 

4import re 

5from typing import TYPE_CHECKING, Any 

6 

7from homeassistant.const import CONF_ENABLED 

8from homeassistant.helpers import issue_registry as ir 

9 

10from .const import ATTR_MEDIA 

11from .model import DeliveryCustomization 

12 

13if TYPE_CHECKING: 

14 from collections.abc import Iterator 

15 

16 from homeassistant.core import HomeAssistant 

17 from homeassistant.helpers.typing import ConfigType 

18 

19 from .delivery import Delivery, DeliveryRegistry 

20 from .hass_api import HomeAssistantAPI 

21 from .schema import ConditionsFunc 

22 

23from contextlib import contextmanager 

24 

25import voluptuous as vol 

26 

27# type: ignore[attr-defined,unused-ignore] 

28from homeassistant.components.trace import async_store_trace 

29from homeassistant.components.trace.models import ActionTrace 

30from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_NAME, CONF_ALIAS, CONF_CONDITIONS 

31from homeassistant.core import Context, HomeAssistant 

32 

33from .const import ATTR_ENABLED, CONF_ACTION_GROUP_NAMES, CONF_DELIVERY, CONF_MEDIA 

34from .model import ConditionVariables 

35 

36_LOGGER = logging.getLogger(__name__) 

37 

38 

39class ScenarioRegistry: 

40 def __init__(self, scenario_configs: ConfigType) -> None: 

41 self._config: ConfigType = scenario_configs or {} 

42 self.scenarios: dict[str, Scenario] = {} 

43 

44 async def initialize( 

45 self, 

46 delivery_registry: DeliveryRegistry, 

47 mobile_actions: ConfigType, 

48 hass_api: HomeAssistantAPI, 

49 ) -> None: 

50 

51 for scenario_name, scenario_definition in self._config.items(): 

52 scenario = Scenario(scenario_name, scenario_definition, delivery_registry, hass_api) 

53 if await scenario.validate(valid_action_group_names=list(mobile_actions)): 

54 self.scenarios[scenario_name] = scenario 

55 else: 

56 _LOGGER.warning("SUPERNOTIFY Scenario %s failed to validate, ignoring", scenario.name) 

57 

58 

59class Scenario: 

60 def __init__( 

61 self, name: str, scenario_definition: dict[str, Any], delivery_registry: DeliveryRegistry, hass_api: HomeAssistantAPI 

62 ) -> None: 

63 self.hass_api: HomeAssistantAPI = hass_api 

64 self.delivery_registry = delivery_registry 

65 self.enabled: bool = scenario_definition.get(CONF_ENABLED, True) 

66 self.name: str = name 

67 self.alias: str | None = scenario_definition.get(CONF_ALIAS) 

68 self.conditions: ConditionsFunc | None = None 

69 self.conditions_config: list[ConfigType] | None = scenario_definition.get(CONF_CONDITIONS) 

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 

77 

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(config=None, default_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(config=None, default_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 = {} 

91 for k, v in delivery_data.items(): 

92 # a wildcard/regex pattern with no explicit enabled: only apply as an 

93 # override to deliveries already selected elsewhere, don't force-enable 

94 # every delivery it happens to match (e.g. selection: scenario deliveries) 

95 self._config_delivery[k] = DeliveryCustomization( 

96 config=v, default_enabled=True if k in delivery_registry.deliveries else None 

97 ) 

98 elif delivery_data: 

99 _LOGGER.warning("SUPERNOTIFY Unable to interpret scenario %s delivery data %s", self.name, delivery_data) 

100 self._config_delivery = {} 

101 else: 

102 _LOGGER.warning("SUPERNOTIFY No delivery definitions for scenario %s", self.name) 

103 self._config_delivery = {} 

104 

105 async def validate(self, valid_action_group_names: list[str] | None = None) -> bool: 

106 """Validate Home Assistant conditiion definition at initiation""" 

107 if self.conditions_config: 

108 error: str | None = None 

109 try: 

110 # note: basic template syntax within conditions already validated by voluptuous checks 

111 self.conditions = await self.hass_api.build_conditions(self.conditions_config, strict=True, validate=True) 

112 except vol.Invalid as vi: 

113 _LOGGER.error( 

114 f"SUPERNOTIFY Condition definition for scenario {self.name} fails Home Assistant schema check {vi}" 

115 ) 

116 error = f"Schema error {vi}" 

117 except Exception as e: 

118 _LOGGER.error( 

119 "SUPERNOTIFY Disabling scenario %s with error validating %s: %s", self.name, self.conditions_config, e 

120 ) 

121 error = f"Unknown error {e}" 

122 if error is not None: 

123 self.startup_issue_count += 1 

124 self.hass_api.raise_issue( 

125 f"scenario_{self.name}_condition", 

126 is_fixable=False, 

127 issue_key="scenario_condition", 

128 issue_map={"scenario": self.name, "error": error}, 

129 severity=ir.IssueSeverity.ERROR, 

130 learn_more_url="https://supernotify.rhizomatics.org.uk/scenarios/", 

131 ) 

132 

133 for name_or_pattern, config in self._config_delivery.items(): 

134 matched: bool = False 

135 delivery: Delivery | None = self.delivery_registry.deliveries.get(name_or_pattern) 

136 if delivery: 

137 self.delivery_overrides[delivery.name] = config 

138 self._delivery_selector[delivery.name] = name_or_pattern 

139 matched = True 

140 else: 

141 # look for a wildcard match instead 

142 for delivery_name in self.delivery_registry.deliveries: 

143 if re.fullmatch(name_or_pattern, delivery_name): 

144 if self._delivery_selector.get(delivery_name) == delivery_name: 

145 _LOGGER.info( 

146 f"SUPERNOTIFY Scenario {self.name} ignoring '{name_or_pattern}' shadowing explicit delivery {delivery_name}" 

147 ) 

148 else: 

149 _LOGGER.debug( 

150 f"SUPERNOTIFY Scenario {self.name} delivery '{name_or_pattern}' matched {delivery_name}" 

151 ) 

152 self.delivery_overrides[delivery_name] = config 

153 self._delivery_selector[delivery_name] = name_or_pattern 

154 matched = True 

155 if not matched: 

156 _LOGGER.error(f"SUPERNOTIFY Scenario {self.name} has delivery {name_or_pattern} not found") 

157 self.startup_issue_count += 1 

158 self.hass_api.raise_issue( 

159 f"scenario_{self.name}_delivery_{name_or_pattern.replace('.', 'DOT').replace('*', 'STAR')}", 

160 is_fixable=False, 

161 issue_key="scenario_delivery", 

162 issue_map={"scenario": self.name, "delivery": name_or_pattern}, 

163 severity=ir.IssueSeverity.WARNING, 

164 learn_more_url="https://supernotify.rhizomatics.org.uk/scenarios/", 

165 ) 

166 

167 if valid_action_group_names is not None: 

168 invalid_action_groups: list[str] = [] 

169 for action_group_name in self.action_groups: 

170 if action_group_name not in valid_action_group_names: 

171 _LOGGER.error(f"SUPERNOTIFY Unknown action group {action_group_name} removed from scenario {self.name}") 

172 invalid_action_groups.append(action_group_name) 

173 self.startup_issue_count += 1 

174 self.hass_api.raise_issue( 

175 f"scenario_{self.name}_action_group_{action_group_name}", 

176 is_fixable=False, 

177 issue_key="scenario_delivery", 

178 issue_map={"scenario": self.name, "action_group": action_group_name}, 

179 severity=ir.IssueSeverity.WARNING, 

180 learn_more_url="https://supernotify.rhizomatics.org.uk/scenarios/", 

181 ) 

182 for action_group_name in invalid_action_groups: 

183 self.action_groups.remove(action_group_name) 

184 

185 return self.startup_issue_count == 0 

186 

187 def enabling_deliveries(self) -> list[str]: 

188 # default_enabled (see __init__/DeliveryCustomization) already resolves whether an 

189 # omitted `enabled` key should count as enabling: True for a directly-named delivery 

190 # (matching the list/string delivery config forms), None for a wildcard/regex match 

191 # so it doesn't force-select every delivery it happens to match. An explicit 

192 # `enabled: None` (e.g. just to carry a priority override) is left as None too, not 

193 # upgraded to the default - only a real `enabled: true` should land here. 

194 return [del_name for del_name, del_config in self.delivery_overrides.items() if del_config.enabled is True] 

195 

196 def relevant_deliveries(self) -> list[str]: 

197 return [ 

198 del_name 

199 for del_name, del_config in self.delivery_overrides.items() 

200 if del_config.enabled or del_config.enabled is None 

201 ] 

202 

203 def disabling_deliveries(self) -> list[str]: 

204 return [del_name for del_name, del_config in self.delivery_overrides.items() if del_config.enabled is False] 

205 

206 def delivery_customization(self, delivery_name: str) -> DeliveryCustomization | None: 

207 return self.delivery_overrides.get(delivery_name) 

208 

209 def attributes(self, include_condition: bool = True, include_trace: bool = False) -> dict[str, Any]: 

210 """Return scenario attributes""" 

211 attrs = { 

212 ATTR_NAME: self.name, 

213 ATTR_ENABLED: self.enabled, 

214 ATTR_MEDIA: self.media, 

215 "action_groups": self.action_groups, 

216 "delivery": self.delivery_overrides, 

217 } 

218 if self.alias: 

219 attrs[ATTR_FRIENDLY_NAME] = self.alias 

220 if include_condition: 

221 attrs["conditions"] = self.conditions_config 

222 if include_trace and self.last_trace: 

223 attrs["trace"] = self.last_trace.as_extended_dict() 

224 return attrs 

225 

226 def delivery_config(self, delivery_name: str) -> DeliveryCustomization | None: 

227 return self.delivery_overrides.get(delivery_name) 

228 

229 def contents(self, minimal: bool = False, **_kwargs: Any) -> dict[str, Any]: 

230 """Archive friendly view of scenario""" 

231 return self.attributes(include_condition=False, include_trace=not minimal) 

232 

233 def evaluate(self, condition_variables: ConditionVariables) -> bool: 

234 """Evaluate scenario conditions""" 

235 result: bool | None = False 

236 if self.enabled and self.conditions: 

237 try: 

238 result = self.hass_api.evaluate_conditions(self.conditions, condition_variables) 

239 if result is None: 

240 _LOGGER.warning(f"SUPERNOTIFY Scenario {self.name} condition empty result") 

241 except Exception as e: 

242 _LOGGER.error( 

243 "SUPERNOTIFY Scenario %s condition eval failed: %s, vars: %s", 

244 self.name, 

245 e, 

246 condition_variables.as_dict() if condition_variables else {}, 

247 ) 

248 return result if result is not None else False 

249 

250 async def trace(self, condition_variables: ConditionVariables) -> bool: 

251 """Trace scenario condition execution""" 

252 result: bool | None = False 

253 trace: ActionTrace | None = None 

254 if self.enabled and self.conditions: 

255 result, trace = await self.hass_api.trace_conditions( 

256 self.conditions, condition_variables, trace_name=f"scenario_{self.name}" 

257 ) 

258 if trace: 

259 self.last_trace = trace 

260 return result if result is not None else False 

261 

262 

263@contextmanager 

264def trace_action( 

265 hass: HomeAssistant, 

266 item_id: str, 

267 config: dict[str, Any], 

268 context: Context | None = None, 

269 stored_traces: int = 5, 

270) -> Iterator[ActionTrace]: 

271 """Trace execution of a scenario.""" 

272 trace = ActionTrace(item_id, config, None, context or Context()) 

273 async_store_trace(hass, trace, stored_traces) 

274 

275 try: 

276 yield trace 

277 except Exception as ex: 

278 if item_id: 

279 trace.set_error(ex) 

280 raise 

281 finally: 

282 if item_id: 

283 trace.finished()