Coverage for custom_components/appliance_autonotify/config_flow.py: 100%

153 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 15:14 +0000

1"""Config flow for Appliance Auto Notify, one config entry per appliance""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload 

8from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_TYPE, UnitOfPower, UnitOfTime 

9from homeassistant.core import HomeAssistant, callback 

10from homeassistant.data_entry_flow import section 

11from homeassistant.helpers.selector import ( 

12 BooleanSelector, 

13 EntitySelector, 

14 EntitySelectorConfig, 

15 NumberSelector, 

16 NumberSelectorConfig, 

17 NumberSelectorMode, 

18 SelectOptionDict, 

19 SelectSelector, 

20 SelectSelectorConfig, 

21 SelectSelectorMode, 

22 TargetSelector, 

23 TextSelector, 

24 TextSelectorConfig, 

25) 

26 

27from .compat import vol 

28from .const import ( 

29 CONF_AUTO_DISCOVER, 

30 CONF_CUSTOM_TARGET, 

31 CONF_DASHBOARD, 

32 CONF_DELIVERIES, 

33 CONF_END_MESSAGE, 

34 CONF_END_TITLE, 

35 CONF_GRACE_PERIOD, 

36 CONF_MIN_DURATION, 

37 CONF_NOTIFY_END, 

38 CONF_NOTIFY_PHASE, 

39 CONF_NOTIFY_PROGRESS, 

40 CONF_NOTIFY_START, 

41 CONF_POWER_ENTITY, 

42 CONF_PROGRAM_IN_TITLE, 

43 CONF_REMINDER, 

44 CONF_REMINDER_REPEATS, 

45 CONF_START_MESSAGE, 

46 CONF_START_TITLE, 

47 CONF_SWITCH_ENTITY, 

48 CONF_SWITCH_OFF_BUTTON, 

49 CONF_TARGET, 

50 CONF_TARGETS, 

51 CONF_THRESHOLD, 

52 DEFAULT_END_MESSAGE, 

53 DEFAULT_GRACE_PERIOD, 

54 DEFAULT_MIN_DURATION, 

55 DEFAULT_REMINDER_REPEATS, 

56 DEFAULT_START_MESSAGE, 

57 DEFAULT_THRESHOLD, 

58 DEFAULT_TITLE, 

59 DISCOVERY_TITLE, 

60 DOMAIN, 

61 NAME_PLACEHOLDER, 

62 PROGRAM_PLACEHOLDER, 

63 SUPERNOTIFY_MISSING, 

64 SUPERNOTIFY_PRESENT, 

65 TYPE_DISCOVERY, 

66 TYPE_POWER, 

67) 

68from .dashboards import dashboards 

69from .discovery import find_appliances 

70from .supernotify_api import deliveries_by_transport, supernotify_available 

71 

72STEP_POWER = "power" 

73STEP_DISCOVERY = "discovery" 

74STEP_SETTINGS = "settings" 

75SECTION_START = "start" 

76SECTION_END = "end" 

77SECTION_NOTIFY = "notify" 

78 

79# a switch left as it comes isn't a setting either 

80DEFAULT_SWITCHES: dict[str, bool] = { 

81 CONF_NOTIFY_START: False, 

82 CONF_NOTIFY_END: True, 

83 CONF_NOTIFY_PROGRESS: False, 

84 CONF_PROGRAM_IN_TITLE: True, 

85} 

86 

87DEFAULT_TEXTS: dict[str, str] = { 

88 CONF_START_TITLE: DEFAULT_TITLE, 

89 CONF_START_MESSAGE: DEFAULT_START_MESSAGE, 

90 CONF_END_TITLE: DEFAULT_TITLE, 

91 CONF_END_MESSAGE: DEFAULT_END_MESSAGE, 

92} 

93 

94 

95def supernotify_status(hass: HomeAssistant) -> str: 

96 return SUPERNOTIFY_PRESENT if supernotify_available(hass) else SUPERNOTIFY_MISSING 

97 

98 

99def discovery_schema(auto_discover: bool = True) -> vol.Schema: 

100 return vol.Schema({vol.Required(CONF_AUTO_DISCOVER, default=auto_discover): BooleanSelector()}) 

101 

102 

103async def settings_schema(hass: HomeAssistant, progress: bool = True, phase: bool | None = None) -> vol.Schema: 

104 fields: dict[vol.Marker, Any] = { 

105 vol.Required(CONF_NOTIFY_START, default=False): BooleanSelector(), 

106 vol.Required(CONF_NOTIFY_END, default=True): BooleanSelector(), 

107 } 

108 if phase is not None: 

109 # only for an appliance with phases, and on or off to begin with by how much they matter for its type 

110 fields[vol.Required(CONF_NOTIFY_PHASE, default=phase)] = BooleanSelector() 

111 if progress: 

112 fields[vol.Required(CONF_NOTIFY_PROGRESS, default=False)] = BooleanSelector() 

113 if supernotify_available(hass): 

114 # only a Live Activity has a title that changes as the cycle goes on 

115 fields[vol.Required(CONF_PROGRAM_IN_TITLE, default=True)] = BooleanSelector() 

116 notify: dict[vol.Marker, Any] = {} 

117 end: dict[vol.Marker, Any] = { 

118 vol.Optional(CONF_END_TITLE): TextSelector(), 

119 vol.Optional(CONF_END_MESSAGE): TextSelector(TextSelectorConfig(multiline=True)), 

120 } 

121 if supernotify_available(hass): 

122 # a choice of what's there, and not a box to type in, so a notification can't be made to open anything else 

123 choices = [SelectOptionDict(value=path, label=title) for path, title in dashboards(hass).items()] 

124 if choices: 

125 fields[vol.Optional(CONF_DASHBOARD)] = SelectSelector( 

126 SelectSelectorConfig(options=choices, mode=SelectSelectorMode.DROPDOWN) 

127 ) 

128 # the same pair of target fields as supernotify.notify itself 

129 notify[vol.Optional(CONF_TARGET)] = TargetSelector() 

130 notify[vol.Optional(CONF_CUSTOM_TARGET)] = TextSelector(TextSelectorConfig(multiple=True)) 

131 deliveries = sorted({name for names in (await deliveries_by_transport(hass)).values() for name in names}) 

132 notify[vol.Optional(CONF_DELIVERIES)] = SelectSelector(SelectSelectorConfig(options=deliveries, multiple=True)) 

133 # buttons and reminders go with the end notification, which only Supernotify can give buttons 

134 end[vol.Optional(CONF_REMINDER)] = NumberSelector( 

135 NumberSelectorConfig(min=0, max=720, step=1, mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfTime.MINUTES) 

136 ) 

137 end[vol.Required(CONF_REMINDER_REPEATS, default=DEFAULT_REMINDER_REPEATS)] = NumberSelector( 

138 NumberSelectorConfig(min=1, max=20, step=1, mode=NumberSelectorMode.BOX) 

139 ) 

140 else: 

141 notify[vol.Optional(CONF_TARGETS)] = EntitySelector(EntitySelectorConfig(domain="notify", multiple=True)) 

142 sections: dict[str, dict[vol.Marker, Any]] = { 

143 SECTION_START: { 

144 vol.Optional(CONF_START_TITLE): TextSelector(), 

145 vol.Optional(CONF_START_MESSAGE): TextSelector(TextSelectorConfig(multiline=True)), 

146 }, 

147 SECTION_END: end, 

148 SECTION_NOTIFY: notify, 

149 } 

150 # all tuning, so out of the way until it's wanted 

151 fields.update({ 

152 vol.Required(name): section(vol.Schema(contents), {"collapsed": True}) for name, contents in sections.items() 

153 }) 

154 return vol.Schema(fields) 

155 

156 

157def sectioned(schema: vol.Schema, values: dict[str, Any]) -> dict[str, Any]: 

158 """Settings are stored flat, the sections are only for display""" 

159 shown: dict[str, Any] = {} 

160 for name, field in schema.schema.items(): 

161 if isinstance(field, section): 

162 shown[str(name)] = {str(key): values[key] for key in field.schema.schema if key in values} 

163 elif name in values: 

164 shown[str(name)] = values[name] 

165 return shown 

166 

167 

168def flattened(user_input: dict[str, Any]) -> dict[str, Any]: 

169 """The settings of a form, with those in sections alongside the rest""" 

170 settings: dict[str, Any] = {} 

171 for name, value in user_input.items(): 

172 settings.update(value if isinstance(value, dict) else {name: value}) 

173 return settings 

174 

175 

176def power_schema(with_name: bool) -> vol.Schema: 

177 fields: dict[vol.Marker, Any] = {} 

178 if with_name: 

179 fields[vol.Required(CONF_NAME)] = TextSelector() 

180 fields[vol.Required(CONF_POWER_ENTITY)] = EntitySelector(EntitySelectorConfig(domain="sensor", device_class="power")) 

181 fields[vol.Required(CONF_THRESHOLD, default=DEFAULT_THRESHOLD)] = NumberSelector( 

182 NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfPower.WATT) 

183 ) 

184 fields[vol.Required(CONF_GRACE_PERIOD, default=DEFAULT_GRACE_PERIOD)] = NumberSelector( 

185 NumberSelectorConfig(min=0, max=3600, step=1, mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfTime.SECONDS) 

186 ) 

187 fields[vol.Required(CONF_MIN_DURATION, default=DEFAULT_MIN_DURATION)] = NumberSelector( 

188 NumberSelectorConfig(min=0, max=3600, step=1, mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfTime.SECONDS) 

189 ) 

190 fields[vol.Optional(CONF_SWITCH_ENTITY)] = EntitySelector(EntitySelectorConfig(domain="switch")) 

191 fields[vol.Required(CONF_SWITCH_OFF_BUTTON, default=True)] = BooleanSelector() 

192 return vol.Schema(fields) 

193 

194 

195class AppliancesConfigFlow(ConfigFlow, domain=DOMAIN): 

196 VERSION = 1 

197 

198 def __init__(self) -> None: 

199 self._device_id: str | None = None 

200 self._name: str = "" 

201 

202 @staticmethod 

203 @callback 

204 def async_get_options_flow(config_entry: ConfigEntry) -> AppliancesOptionsFlow: 

205 return AppliancesOptionsFlow() 

206 

207 async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

208 """Automatic discovery the first time, and from then on an appliance to describe by hand""" 

209 if any(entry.data.get(CONF_TYPE) == TYPE_DISCOVERY for entry in self._async_current_entries()): 

210 return await self.async_step_power() 

211 if user_input is not None: 

212 await self.async_set_unique_id(TYPE_DISCOVERY) 

213 self._abort_if_unique_id_configured() 

214 # the appliances themselves are set up as this entry is, each as its own entry to change or disable 

215 return self.async_create_entry(title=DISCOVERY_TITLE, data={CONF_TYPE: TYPE_DISCOVERY}, options=user_input) 

216 configured = self._async_current_ids() 

217 found = [a.name for a in find_appliances(self.hass) if a.device_id not in configured] 

218 if found: 

219 found_text = "Appliances found:\n\n" + "\n".join(f"- {name}" for name in found) 

220 else: 

221 found_text = "No appliances could be automatically installed." 

222 return self.async_show_form( 

223 step_id="user", 

224 data_schema=discovery_schema(), 

225 description_placeholders={"found": found_text, "supernotify": supernotify_status(self.hass)}, 

226 ) 

227 

228 async def async_step_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

229 """An appliance not found by itself, with a power monitor to tell when it's running""" 

230 if user_input is not None: 

231 await self.async_set_unique_id(user_input[CONF_POWER_ENTITY]) 

232 self._abort_if_unique_id_configured() 

233 return self.async_create_entry( 

234 title=user_input[CONF_NAME], 

235 data={CONF_TYPE: TYPE_POWER}, 

236 options={k: v for k, v in user_input.items() if k != CONF_NAME}, 

237 ) 

238 return self.async_show_form(step_id=STEP_POWER, data_schema=power_schema(with_name=True)) 

239 

240 async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: 

241 """An appliance found with automatic discovery on, so set up without asking""" 

242 # a discovery of the same appliance may be waiting, and goes once this entry exists 

243 await self.async_set_unique_id(import_data[CONF_DEVICE_ID], raise_on_progress=False) 

244 self._abort_if_unique_id_configured() 

245 return self.async_create_entry(title=import_data[CONF_NAME], data={CONF_DEVICE_ID: import_data[CONF_DEVICE_ID]}) 

246 

247 async def async_step_integration_discovery(self, discovery_info: dict[str, Any]) -> ConfigFlowResult: 

248 """An appliance found with automatic discovery off, so offered to be added""" 

249 self._device_id = discovery_info[CONF_DEVICE_ID] 

250 self._name = discovery_info[CONF_NAME] 

251 await self.async_set_unique_id(self._device_id) 

252 self._abort_if_unique_id_configured() 

253 self.context["title_placeholders"] = {"name": self._name} 

254 return await self.async_step_confirm() 

255 

256 async def async_step_confirm(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

257 if user_input is not None: 

258 return self.async_create_entry(title=self._name, data={CONF_DEVICE_ID: self._device_id}) 

259 self._set_confirm_only() 

260 return self.async_show_form( 

261 step_id="confirm", 

262 description_placeholders={"name": self._name, "supernotify": supernotify_status(self.hass)}, 

263 ) 

264 

265 

266class AppliancesOptionsFlow(OptionsFlowWithReload): 

267 def __init__(self) -> None: 

268 self._settings: dict[str, Any] = {} 

269 

270 def _name(self) -> str: 

271 # the name notifications are sent with, which follows the device rather than the entry's title 

272 watcher = getattr(self.config_entry, "runtime_data", None) 

273 return watcher.name if watcher is not None else self.config_entry.title 

274 

275 def _phase_default(self) -> bool | None: 

276 return getattr(getattr(self.config_entry, "runtime_data", None), "phase_default", None) 

277 

278 async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

279 if self.config_entry.data.get(CONF_TYPE) == TYPE_DISCOVERY: 

280 return await self.async_step_discovery() 

281 return await self.async_step_settings() 

282 

283 async def async_step_discovery(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

284 if user_input is not None: 

285 return self.async_create_entry(data=user_input) 

286 return self.async_show_form( 

287 step_id=STEP_DISCOVERY, 

288 data_schema=discovery_schema(self.config_entry.options.get(CONF_AUTO_DISCOVER, True)), 

289 ) 

290 

291 async def async_step_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

292 name = self._name() 

293 # as they'll be sent, so there's the real text to edit rather than a template or an empty box 

294 defaults = {key: text.replace(NAME_PLACEHOLDER, name) for key, text in DEFAULT_TEXTS.items()} 

295 if user_input is not None: 

296 # text left as the default isn't a setting, so it goes on following the name of the appliance 

297 unset = ( 

298 defaults 

299 | DEFAULT_SWITCHES 

300 | {CONF_NOTIFY_PHASE: self._phase_default(), CONF_REMINDER_REPEATS: DEFAULT_REMINDER_REPEATS} 

301 ) 

302 settings = { 

303 key: value 

304 for key, value in flattened(user_input).items() 

305 if (value or value is False) and value != unset.get(key) 

306 } 

307 if self.config_entry.data.get(CONF_TYPE) == TYPE_POWER: 

308 self._settings = settings 

309 return await self.async_step_power() 

310 return self.async_create_entry(data=settings) 

311 # a power monitored appliance has no progress to report 

312 schema = await settings_schema( 

313 self.hass, progress=self.config_entry.data.get(CONF_TYPE) != TYPE_POWER, phase=self._phase_default() 

314 ) 

315 shown = dict(self.config_entry.options) 

316 if shown.get(CONF_DASHBOARD) not in dashboards(self.hass): 

317 # removed since it was chosen 

318 shown.pop(CONF_DASHBOARD, None) 

319 for key, text in defaults.items(): 

320 shown[key] = str(shown.get(key) or text).replace(NAME_PLACEHOLDER, name) 

321 return self.async_show_form( 

322 step_id=STEP_SETTINGS, 

323 data_schema=self.add_suggested_values_to_schema(schema, sectioned(schema, shown)), 

324 description_placeholders={ 

325 "name": name, 

326 "supernotify": supernotify_status(self.hass), 

327 # as it's to be typed, which a translation can't say for itself 

328 "program_placeholder": PROGRAM_PLACEHOLDER, 

329 }, 

330 ) 

331 

332 async def async_step_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 

333 if user_input is not None: 

334 return self.async_create_entry(data=self._settings | user_input) 

335 return self.async_show_form( 

336 step_id=STEP_POWER, 

337 data_schema=self.add_suggested_values_to_schema(power_schema(with_name=False), self.config_entry.options), 

338 description_placeholders={"name": self.config_entry.title}, 

339 )