Coverage for custom_components/supernotify/delivery.py: 99%

160 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-21 23:31 +0000

1import logging 

2import re 

3from typing import Any 

4 

5from homeassistant.const import ( 

6 ATTR_FRIENDLY_NAME, 

7 ATTR_NAME, 

8 CONF_ACTION, 

9 CONF_ALIAS, 

10 CONF_CONDITION, 

11 CONF_DEBUG, 

12 CONF_ENABLED, 

13 CONF_NAME, 

14 CONF_OPTIONS, 

15) 

16from homeassistant.helpers.typing import ConfigType 

17 

18from custom_components.supernotify.model import ConditionVariables, DeliveryConfig, Target 

19from custom_components.supernotify.transport import Transport 

20 

21from . import ( 

22 ATTR_ENABLED, 

23 CONF_MESSAGE, 

24 CONF_OCCUPANCY, 

25 CONF_SELECTION, 

26 CONF_TEMPLATE, 

27 CONF_TITLE, 

28 CONF_TRANSPORT, 

29 OCCUPANCY_ALL, 

30 OPTION_TARGET_CATEGORIES, 

31 OPTION_TARGET_INCLUDE_RE, 

32 RESERVED_DELIVERY_NAMES, 

33 SELECTION_DEFAULT, 

34 SELECTION_FALLBACK, 

35 SELECTION_FALLBACK_ON_ERROR, 

36) 

37from .context import Context 

38 

39_LOGGER = logging.getLogger(__name__) 

40 

41 

42class Delivery(DeliveryConfig): 

43 def __init__(self, name: str, conf: ConfigType, transport: "Transport") -> None: 

44 self.name: str = name 

45 self.alias: str | None = conf.get(CONF_ALIAS) 

46 self.transport: Transport = transport 

47 transport_defaults: DeliveryConfig = self.transport.delivery_defaults 

48 super().__init__(conf, delivery_defaults=transport_defaults) 

49 self.template: str | None = conf.get(CONF_TEMPLATE) 

50 self.message: str | None = conf.get(CONF_MESSAGE) 

51 self.title: str | None = conf.get(CONF_TITLE) 

52 self.enabled: bool = conf.get(CONF_ENABLED, self.transport.enabled) 

53 self.occupancy: str = conf.get(CONF_OCCUPANCY, OCCUPANCY_ALL) 

54 self.condition: ConfigType | None = conf.get(CONF_CONDITION) 

55 

56 async def validate(self, context: "Context") -> bool: 

57 errors = 0 

58 if self.name in RESERVED_DELIVERY_NAMES: 

59 _LOGGER.warning("SUPERNOTIFY Delivery uses reserved word %s", self.name) 

60 context.hass_api.raise_issue( 

61 f"delivery_{self.name}_reserved_name", 

62 issue_key="delivery_reserved_name", 

63 issue_map={"delivery": self.name}, 

64 learn_more_url="https://supernotify.rhizomatics.org.uk/deliveries", 

65 ) 

66 errors += 1 

67 if not self.transport.validate_action(self.action): 

68 _LOGGER.warning("SUPERNOTIFY Invalid action definition for delivery %s (%s)", self.name, self.action) 

69 context.hass_api.raise_issue( 

70 f"delivery_{self.name}_invalid_action", 

71 issue_key="delivery_invalid_action", 

72 issue_map={"delivery": self.name, "action": self.action or ""}, 

73 learn_more_url="https://supernotify.rhizomatics.org.uk/deliveries", 

74 ) 

75 errors += 1 

76 

77 if self.condition: 

78 try: 

79 await context.hass_api.evaluate_condition(self.condition, validate=True, strict=True) 

80 passed = True 

81 exception = "" 

82 except Exception as e: 

83 passed = False 

84 exception = str(e) 

85 if not passed: 

86 _LOGGER.warning("SUPERNOTIFY Invalid delivery condition for %s: %s", self.name, self.condition) 

87 context.hass_api.raise_issue( 

88 f"delivery_{self.name}_invalid_condition", 

89 issue_key="delivery_invalid_condition", 

90 issue_map={"delivery": self.name, "condition": str(self.condition), "exception": exception}, 

91 learn_more_url="https://supernotify.rhizomatics.org.uk/deliveries", 

92 ) 

93 errors += 1 

94 return errors == 0 

95 

96 def select_targets(self, target: Target) -> Target: 

97 def selected(category: str, targets: list[str]) -> list[str]: 

98 if OPTION_TARGET_CATEGORIES in self.options and category not in self.options[OPTION_TARGET_CATEGORIES]: 

99 return [] 

100 if OPTION_TARGET_INCLUDE_RE in self.options: 

101 return [t for t in targets if any(re.fullmatch(r, t) for r in self.options[OPTION_TARGET_INCLUDE_RE])] 

102 return targets 

103 

104 filtered_target = Target({k: selected(k, v) for k, v in target.targets.items()}, target_data=target.target_data) 

105 # TODO: in model class 

106 if target.target_specific_data: 

107 filtered_target.target_specific_data = { 

108 (c, t): data 

109 for (c, t), data in target.target_specific_data.items() 

110 if c in target.targets and t in target.targets[c] 

111 } 

112 return filtered_target 

113 

114 async def evaluate_conditions(self, condition_variables: ConditionVariables | None) -> bool | None: 

115 if not self.enabled: 

116 return False 

117 if self.condition is None: 

118 return True 

119 # TODO: reconsider hass_api injection 

120 return await self.transport.hass_api.evaluate_condition(self.condition, condition_variables) 

121 

122 def option(self, option_name: str) -> str | bool: 

123 """Get an option value from delivery config or transport default options""" 

124 opt: str | bool | None = None 

125 if option_name in self.options: 

126 opt = self.options[option_name] 

127 if opt is None: 

128 _LOGGER.debug("SUPERNOTIFY No default in %s for option %s, setting to empty string", self.name, option_name) 

129 opt = "" 

130 return opt 

131 

132 def option_bool(self, option_name: str) -> bool: 

133 return bool(self.option(option_name)) 

134 

135 def option_str(self, option_name: str) -> str: 

136 return str(self.option(option_name)) 

137 

138 def as_dict(self) -> dict[str, Any]: 

139 base = super().as_dict() 

140 base.update({ 

141 CONF_NAME: self.name, 

142 CONF_ALIAS: self.alias, 

143 CONF_TRANSPORT: self.transport.name, 

144 CONF_TEMPLATE: self.template, 

145 CONF_MESSAGE: self.message, 

146 CONF_TITLE: self.title, 

147 CONF_ENABLED: self.enabled, 

148 CONF_OCCUPANCY: self.occupancy, 

149 CONF_CONDITION: self.condition, 

150 }) 

151 return base 

152 

153 def attributes(self) -> dict[str, Any]: 

154 """For exposure as entity state""" 

155 attrs: dict[str, Any] = { 

156 ATTR_NAME: self.name, 

157 ATTR_ENABLED: self.enabled, 

158 CONF_TRANSPORT: self.transport.name, 

159 CONF_ACTION: self.action, 

160 CONF_OPTIONS: self.options, 

161 CONF_SELECTION: self.selection, 

162 CONF_DEBUG: self.debug, 

163 } 

164 if self.alias: 

165 attrs[ATTR_FRIENDLY_NAME] = self.alias 

166 return attrs 

167 

168 

169class DeliveryRegistry: 

170 def __init__( 

171 self, 

172 deliveries: ConfigType | None = None, 

173 transport_configs: ConfigType | None = None, 

174 transport_types: list[type[Transport]] | None = None, 

175 # for unit tests only 

176 transport_instances: list[Transport] | None = None, 

177 ) -> None: 

178 # raw configured deliveries 

179 self._deliveries: ConfigType = deliveries if isinstance(deliveries, dict) else {} 

180 # validated deliveries 

181 self.deliveries: dict[str, Delivery] = {} 

182 self.transports: dict[str, Transport] = {} 

183 self._transport_configs: ConfigType = transport_configs or {} 

184 self._fallback_on_error: list[Delivery] = [] 

185 self._fallback_by_default: list[Delivery] = [] 

186 self._implicit_deliveries: list[Delivery] = [] 

187 self._transport_types: list[type[Transport]] = transport_types or [] 

188 # test harness support 

189 self._transport_instances: list[Transport] | None = transport_instances 

190 

191 async def initialize(self, context: "Context") -> None: 

192 await self.initialize_transports(context) 

193 self.autogenerate_deliveries() 

194 self.initialize_deliveries() 

195 

196 def initialize_deliveries(self) -> None: 

197 for delivery in self.deliveries.values(): 

198 if delivery.enabled: 

199 if SELECTION_FALLBACK_ON_ERROR in delivery.selection: 

200 self._fallback_on_error.append(delivery) 

201 if SELECTION_FALLBACK in delivery.selection: 

202 self._fallback_by_default.append(delivery) 

203 if SELECTION_DEFAULT in delivery.selection: 

204 self._implicit_deliveries.append(delivery) 

205 

206 @property 

207 def fallback_by_default_deliveries(self) -> list[Delivery]: 

208 return [d for d in self._fallback_by_default if d.enabled] 

209 

210 @property 

211 def fallback_on_error_deliveries(self) -> list[Delivery]: 

212 return [d for d in self._fallback_on_error if d.enabled] 

213 

214 @property 

215 def implicit_deliveries(self) -> list[Delivery]: 

216 """Deliveries switched on all the time for implicit selection""" 

217 return [d for d in self._implicit_deliveries if d.enabled] 

218 

219 async def initialize_transports(self, context: "Context") -> None: 

220 """Use configure_for_tests() to set transports to mocks or manually created fixtures""" 

221 if self._transport_instances: 

222 for transport in self._transport_instances: 

223 self.transports[transport.name] = transport 

224 await transport.initialize() 

225 await self.initialize_transport_deliveries(context, transport) 

226 if self._transport_types: 

227 for transport_class in self._transport_types: 

228 transport_config: ConfigType = self._transport_configs.get(transport_class.name, {}) 

229 transport = transport_class(context, transport_config) 

230 self.transports[transport_class.name] = transport 

231 await transport.initialize() 

232 await self.initialize_transport_deliveries(context, transport) 

233 self.transports[transport_class.name] = transport 

234 

235 unconfigured_deliveries = [dc for d, dc in self._deliveries.items() if d not in self.deliveries] 

236 for bad_del in unconfigured_deliveries: 

237 # presumably there was no transport for these 

238 context.hass_api.raise_issue( 

239 f"delivery_{bad_del.get(CONF_NAME)}_for_transport_{bad_del.get(CONF_TRANSPORT)}_failed_to_configure", 

240 issue_key="delivery_unknown_transport", 

241 issue_map={"delivery": bad_del.get(CONF_NAME), "transport": bad_del.get(CONF_TRANSPORT)}, 

242 learn_more_url="https://supernotify.rhizomatics.org.uk/deliveries", 

243 ) 

244 _LOGGER.info("SUPERNOTIFY configured deliveries %s", "; ".join(self.deliveries.keys())) 

245 

246 async def initialize_transport_deliveries(self, context: Context, transport: Transport) -> None: 

247 """Validate and initialize deliveries at startup for this transport""" 

248 validated_deliveries: dict[str, Delivery] = {} 

249 deliveries_for_this_transport = { 

250 d: dc for d, dc in self._deliveries.items() if dc.get(CONF_TRANSPORT) == transport.name 

251 } 

252 for d, dc in deliveries_for_this_transport.items(): 

253 # don't care about ENABLED here since disabled deliveries can be overridden later 

254 delivery = Delivery(d, dc, transport) 

255 if not await delivery.validate(context): 

256 _LOGGER.error(f"SUPERNOTIFY Ignoring delivery {d} with errors") 

257 else: 

258 validated_deliveries[d] = delivery 

259 

260 self.deliveries.update(validated_deliveries) 

261 

262 _LOGGER.debug( 

263 "SUPERNOTIFY Validated transport %s, default action %s, valid deliveries: %s", 

264 transport.name, 

265 transport.delivery_defaults.action, 

266 [d for d in self.deliveries.values() if d.enabled and d.transport == transport], 

267 ) 

268 

269 def autogenerate_deliveries(self) -> None: 

270 # If the config has no deliveries, check if a default delivery should be auto-generated 

271 # where there is a empty config, supernotify can at least handle NotifyEntities sensibly 

272 

273 autogenerated: dict[str, Delivery] = {} 

274 for transport in self.transports.values(): 

275 if any(dc for dc in self._deliveries.values() if dc.get(CONF_TRANSPORT) == transport.name): 

276 continue 

277 

278 transport_definition: DeliveryConfig = transport.delivery_defaults 

279 _LOGGER.debug( 

280 "SUPERNOTIFY Building default delivery for %s from transport %s", transport.name, transport_definition 

281 ) 

282 if transport.auto_configure and transport.enabled and transport.validate_action(transport_definition.action): 

283 # auto generate a delivery that will be implicitly selected 

284 default_delivery = Delivery(f"DEFAULT_{transport.name}", transport_definition.as_dict(), transport) 

285 default_delivery.enabled = transport.enabled 

286 autogenerated[default_delivery.name] = default_delivery 

287 _LOGGER.info( 

288 "SUPERNOTIFY Auto-generating a default delivery for %s from transport %s", 

289 transport.name, 

290 transport_definition, 

291 ) 

292 else: 

293 _LOGGER.debug("SUPERNOTIFY No default delivery or transport_definition for transport %s", transport.name) 

294 if autogenerated: 

295 self.deliveries.update(autogenerated)