Coverage for custom_components/supernotify/config_flow.py: 98%

156 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 14:29 +0000

1"""Config flow for the Supernotify integration. 

2 

3A UI setup path that reproduces examples/minimal.yaml (everything auto-discovered, no required 

4fields), plus options pages for the archive, dupe_check and housekeeping settings. Delivery, 

5transports, scenarios, scenario_control, recipients, cameras, action_groups, links and snooze stay 

6YAML-only, now 

7under a top-level `supernotify:` key (see CONFIG_SCHEMA/async_setup in __init__.py) rather than 

8the legacy `notify: - platform: supernotify` block - this config entry is the sole, 

9unconditional owner of registering notify.supernotify in every case. 

10""" 

11 

12from __future__ import annotations 

13 

14import logging 

15from typing import Any 

16 

17import voluptuous as vol 

18from anyio import Path 

19from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow 

20from homeassistant.const import CONF_ENABLED, CONF_NAME 

21from homeassistant.data_entry_flow import section 

22from homeassistant.helpers import config_validation as cv 

23from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig 

24 

25from . import ARCHIVE_DIR, DOMAIN, MEDIA_DIR, TEMPLATE_DIR 

26from .const import ( 

27 ATTR_DUPE_POLICY_MT, 

28 ATTR_DUPE_POLICY_MTSLP, 

29 ATTR_DUPE_POLICY_NONE, 

30 CONF_ARCHIVE, 

31 CONF_ARCHIVE_DAYS, 

32 CONF_ARCHIVE_DIAGNOSTICS, 

33 CONF_ARCHIVE_EVENT_NAME, 

34 CONF_ARCHIVE_EVENT_SELECTION, 

35 CONF_ARCHIVE_MQTT_QOS, 

36 CONF_ARCHIVE_MQTT_RETAIN, 

37 CONF_ARCHIVE_MQTT_TOPIC, 

38 CONF_ARCHIVE_PATH, 

39 CONF_ARCHIVE_PURGE_INTERVAL, 

40 CONF_DEFAULT_INCLUSION, 

41 CONF_DELIVERY_CONTROL, 

42 CONF_DUPE_CHECK, 

43 CONF_DUPE_POLICY, 

44 CONF_HOUSEKEEPING, 

45 CONF_HOUSEKEEPING_TIME, 

46 CONF_LLM_ACTION_TOOLS, 

47 CONF_LLM_DIAGNOSTIC_TOOLS, 

48 CONF_LLM_TOOLS, 

49 CONF_MEDIA_PATH, 

50 CONF_MEDIA_STORAGE_DAYS, 

51 CONF_MEDIA_URL_PREFIX, 

52 CONF_MOBILE_DISCOVERY, 

53 CONF_RECIPIENTS_DISCOVERY, 

54 CONF_SENTENCE_COMMANDS, 

55 CONF_SIZE, 

56 CONF_TEMPLATE_PATH, 

57 CONF_TTL, 

58 CONF_VOICE_OCCUPANCY, 

59 DEFAULT_INCLUSION_VALUES, 

60 OCCUPANCY_VALUES, 

61) 

62from .schema import OutcomeSelection 

63 

64_LOGGER = logging.getLogger(__name__) 

65 

66_DUPE_POLICIES = [ATTR_DUPE_POLICY_MTSLP, ATTR_DUPE_POLICY_MT, ATTR_DUPE_POLICY_NONE] 

67 

68# Linked from the options pages that have their own documentation page - the "?" help icon can only 

69# link to the manifest's one documentation URL 

70ARCHIVE_DOCS_URL = "https://supernotify.rhizomatics.org.uk/latest/configuration/archiving/" 

71DUPE_CHECK_DOCS_URL = "https://supernotify.rhizomatics.org.uk/latest/configuration/dupe_detection/" 

72 

73# The Delivery Control choices meaning "not set", which leave deliveries with their transport's own 

74# default - never stored, the option is left out instead 

75NOT_SET_INCLUSION = "transport" 

76NOT_SET_OCCUPANCY = "not_controlled" 

77 

78 

79def _event_policy_str(value: Any) -> str: # ruff: ignore[any-type] 

80 """Render an OutcomeSelection as the pipe-separated name string parse_event_policy 

81 expects (e.g. "ERROR|DUPE"), whatever form it currently happens to be in. 

82 

83 A YAML-imported archive config already went through ARCHIVE_SCHEMA, which turns 

84 event_selection/diagnostics into OutcomeSelection (IntFlag) instances - stored as a raw 

85 int once round-tripped through config-entry storage. Left unconverted, that raw int would 

86 show up as a bare number in this form instead of the "ERROR"-style text it's meant to be. 

87 """ 

88 if isinstance(value, str): 

89 return value 

90 if isinstance(value, int): 

91 try: 

92 return OutcomeSelection(value).name or "NONE" 

93 except ValueError: 

94 return "NONE" 

95 return str(value) 

96 

97 

98# NONE isn't a real, independently selectable outcome - it's the empty bitmask, and an 

99# always-false no-op check in archive.py (outcome_policy & OutcomeSelection.NONE is always 

100# 0). "No outcomes ticked" already means NONE, so it's excluded from the checkbox list. 

101# Option values are lowercased since HA selector translation keys must match [a-z0-9-_]+ - 

102# the stored/parsed policy strings stay uppercase (OutcomeSelection member names). 

103_OUTCOME_OPTIONS = [flag.name.lower() for flag in OutcomeSelection if flag != OutcomeSelection.NONE and flag.name is not None] 

104 

105 

106def _event_policy_to_list(value: str) -> list[str]: 

107 """Turn a stored OutcomeSelection value into the list of names a multi-select needs.""" 

108 policy_str = _event_policy_str(value) 

109 return [] if policy_str in ("", "NONE") else [part.lower() for part in policy_str.split("|")] 

110 

111 

112def _event_policy_from_list(values: list[str]) -> str: 

113 """Turn a submitted multi-select list back into the pipe-separated string 

114 ARCHIVE_SCHEMA's parse_event_policy expects.""" 

115 return "|".join(value.upper() for value in values) if values else "NONE" 

116 

117 

118def extract_legacy_options(import_data: dict[str, Any]) -> dict[str, Any]: 

119 """Pull the archive/dupe_check/housekeeping option blocks out of a legacy YAML config dict, 

120 normalizing archive's event_selection/diagnostics the same way a fresh entry would. 

121 

122 Shared by async_step_import (fresh entry bootstrap) and repairs.py's migration flow, which 

123 also needs this when merging legacy config into an entry that already exists - e.g. one 

124 auto-bootstrapped blank by async_setup before the interactive repair ever runs (see 

125 repairs.py's async_sync_entry_from_legacy_config). 

126 """ 

127 archive: dict[str, Any] = dict(import_data.get(CONF_ARCHIVE) or {}) 

128 if CONF_ARCHIVE_EVENT_SELECTION in archive: 

129 archive[CONF_ARCHIVE_EVENT_SELECTION] = _event_policy_str(archive[CONF_ARCHIVE_EVENT_SELECTION]) 

130 if CONF_ARCHIVE_DIAGNOSTICS in archive: 

131 archive[CONF_ARCHIVE_DIAGNOSTICS] = _event_policy_str(archive[CONF_ARCHIVE_DIAGNOSTICS]) 

132 

133 housekeeping: dict[str, Any] = dict(import_data.get(CONF_HOUSEKEEPING) or {}) 

134 housekeeping_time = housekeeping.get(CONF_HOUSEKEEPING_TIME) 

135 if housekeeping_time is not None and not isinstance(housekeeping_time, str): 

136 # cv.time on the YAML side already coerced this into a datetime.time 

137 housekeeping[CONF_HOUSEKEEPING_TIME] = housekeeping_time.isoformat() 

138 

139 options: dict[str, Any] = {} 

140 if archive: 

141 options[CONF_ARCHIVE] = archive 

142 if import_data.get(CONF_DUPE_CHECK): 

143 options[CONF_DUPE_CHECK] = import_data[CONF_DUPE_CHECK] 

144 if housekeeping: 

145 options[CONF_HOUSEKEEPING] = housekeeping 

146 return options 

147 

148 

149def extract_legacy_data(import_data: dict[str, Any]) -> dict[str, Any]: 

150 """Pull the template_path/media_path/media_url_prefix/mobile_discovery/recipients_discovery 

151 settings out of a legacy YAML config dict, defaulting anything not set - the same defaults a 

152 fresh entry would get. Deliberately excludes `name`, which repairs.py's 

153 async_sync_entry_from_legacy_config merges in separately (it isn't defaulted the same way). 

154 

155 Shared by async_step_import (fresh entry bootstrap) and repairs.py's migration flow, which 

156 also needs this when merging legacy config into an entry that already exists - e.g. one 

157 auto-bootstrapped blank by async_setup before the interactive repair ever runs (see 

158 repairs.py's async_sync_entry_from_legacy_config). Without this, a "simple" install with 

159 nothing that needs a repair (no delivery/transports/scenarios/etc to move into 

160 supernotify.yaml) would silently keep running on defaults forever, ignoring a customized 

161 template_path/media_path/etc in the legacy block. 

162 """ 

163 return { 

164 CONF_TEMPLATE_PATH: import_data.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR), 

165 CONF_MEDIA_PATH: import_data.get(CONF_MEDIA_PATH, MEDIA_DIR), 

166 CONF_MEDIA_URL_PREFIX: import_data.get(CONF_MEDIA_URL_PREFIX, "/supernotify/media"), 

167 CONF_MOBILE_DISCOVERY: import_data.get(CONF_MOBILE_DISCOVERY, True), 

168 CONF_RECIPIENTS_DISCOVERY: import_data.get(CONF_RECIPIENTS_DISCOVERY, True), 

169 } 

170 

171 

172def _user_schema(defaults: dict[str, Any] | None = None) -> vol.Schema: 

173 # cv.string, not cv.path: cv.path fails voluptuous-serialize schema conversion used by 

174 # the config flow frontend ("Unable to convert schema" / HTTP 500). 

175 defaults = defaults or {} 

176 return vol.Schema({ 

177 # Determines the registered notify.<name> action (slugified) - matches the legacy 

178 # notify platform's `name:` YAML field, which this replaces as the sole source of truth. 

179 vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, DOMAIN)): cv.string, 

180 vol.Optional(CONF_TEMPLATE_PATH, default=defaults.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR)): cv.string, 

181 vol.Optional(CONF_MEDIA_PATH, default=defaults.get(CONF_MEDIA_PATH, MEDIA_DIR)): cv.string, 

182 vol.Optional(CONF_MEDIA_URL_PREFIX, default=defaults.get(CONF_MEDIA_URL_PREFIX, "/supernotify/media")): cv.string, 

183 vol.Optional(CONF_MOBILE_DISCOVERY, default=defaults.get(CONF_MOBILE_DISCOVERY, True)): cv.boolean, 

184 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=defaults.get(CONF_RECIPIENTS_DISCOVERY, True)): cv.boolean, 

185 }) 

186 

187 

188async def _ensure_directory_exists(path_str: str) -> str | None: 

189 """Create a directory if it doesn't exist yet, mirroring MediaStorage.initialize()'s own 

190 tolerant runtime behavior (media_grab.py). Returns an error description on a genuine 

191 failure to create/access it, None on success.""" 

192 try: 

193 path = Path(path_str) 

194 if not path.is_absolute(): 

195 path = await path.absolute() 

196 if not await path.exists(): 

197 await path.mkdir(parents=True, exist_ok=True) 

198 except (OSError, ValueError) as err: 

199 return str(err) 

200 return None 

201 

202 

203async def _validate_user_input(user_input: dict[str, Any]) -> dict[str, str]: 

204 """Validate template_path/media_path at config-flow submission time, creating each 

205 directory if it doesn't exist yet. Catching a genuine failure here surfaces a typo'd or 

206 unwritable path immediately in the wizard, instead of leaving the user to discover it 

207 later via a repair issue. A blank value is left untouched - both fields are optional. 

208 """ 

209 errors: dict[str, str] = {} 

210 

211 template_path = user_input.get(CONF_TEMPLATE_PATH) 

212 if template_path: 

213 error = await _ensure_directory_exists(template_path) 

214 if error: 

215 errors[CONF_TEMPLATE_PATH] = "template_path_invalid" 

216 _LOGGER.debug("SUPERNOTIFY Invalid template_path %s: %s", template_path, error) 

217 

218 media_path = user_input.get(CONF_MEDIA_PATH) 

219 if media_path: 

220 error = await _ensure_directory_exists(media_path) 

221 if error: 

222 errors[CONF_MEDIA_PATH] = "media_path_invalid" 

223 _LOGGER.debug("SUPERNOTIFY Invalid media_path %s: %s", media_path, error) 

224 

225 return errors 

226 

227 

228class SupernotifyConfigFlow(ConfigFlow, domain=DOMAIN): 

229 """Handle a Supernotify config flow.""" 

230 

231 VERSION = 1 

232 

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

234 errors: dict[str, str] = {} 

235 if user_input is not None: 

236 errors = await _validate_user_input(user_input) 

237 if not errors: 

238 return self.async_create_entry(title="Supernotify", data=user_input) 

239 return self.async_show_form(step_id="user", data_schema=_user_schema(user_input), errors=errors) 

240 

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

242 """Let the user change the global settings of an existing entry. 

243 

244 These fields are config-entry *data* (set at initial setup), not options, so HA's 

245 guidance is a reconfigure step rather than the options flow - which instead covers 

246 archive/dupe_check/housekeeping, genuine runtime preferences. 

247 """ 

248 entry = self._get_reconfigure_entry() 

249 errors: dict[str, str] = {} 

250 if user_input is not None: 

251 errors = await _validate_user_input(user_input) 

252 if not errors: 

253 # async_update_and_abort, not async_update_reload_and_abort: async_setup_entry 

254 # registers an update listener, which async_update_entry already fires (and 

255 # reloads via) whenever entry.data changes - calling the "_reload" variant too 

256 # would reload twice and log an HA deprecation warning about the redundancy. 

257 return self.async_update_and_abort(entry, data_updates=user_input) 

258 defaults = user_input if user_input is not None else dict(entry.data) 

259 return self.async_show_form(step_id="reconfigure", data_schema=_user_schema(defaults), errors=errors) 

260 

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

262 """Bootstrap a config entry, optionally seeded from a legacy YAML config dict. 

263 

264 Two callers: __init__.py's async_setup (a from-scratch install with no entry yet) and 

265 async_reload_yaml_config_and_entries's bootstrap, and repairs.py's migration flow (a 

266 leftover legacy `notify: - platform: supernotify` block, possibly with real 

267 archive/housekeeping/dupe_check/name/template_path/etc already set) - both pass 

268 `data={}` for a genuinely fresh install, or the raw legacy config dict to preserve an 

269 existing installation's settings (notably `name`, which determines the registered 

270 notify.<name> action - see __init__.py's async_setup_entry). 

271 

272 Duplicate-entry protection is the same single_config_entry manifest flag used by the 

273 user step (it covers SOURCE_IMPORT too), so no unique_id bookkeeping is needed here. 

274 """ 

275 data: dict[str, Any] = { 

276 CONF_NAME: import_data.get(CONF_NAME, DOMAIN), 

277 **extract_legacy_data(import_data), 

278 } 

279 

280 options = extract_legacy_options(import_data) 

281 

282 _LOGGER.info( 

283 "SUPERNOTIFY Config entry bootstrapped (data=%s,options=%s)", 

284 ";".join(data.keys()), 

285 ";".join(options.keys()), 

286 ) 

287 return self.async_create_entry(title="Supernotify", data=data, options=options) 

288 

289 @staticmethod 

290 def async_get_options_flow(config_entry: ConfigEntry) -> SupernotifyOptionsFlow: 

291 return SupernotifyOptionsFlow() 

292 

293 

294class SupernotifyOptionsFlow(OptionsFlow): 

295 """Options pages for archive, dupe_check, housekeeping and LLM tools settings.""" 

296 

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

298 return self.async_show_menu( 

299 step_id="init", menu_options=["archive", "dupe_check", "housekeeping", "delivery_control", "llm_tools"] 

300 ) 

301 

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

303 current: dict[str, Any] = self.config_entry.options.get(CONF_ARCHIVE, {}) 

304 if user_input is not None: 

305 file_section = user_input["file"] 

306 mqtt_section = user_input["mqtt"] 

307 event_section = user_input["event"] 

308 processed = { 

309 CONF_ENABLED: user_input[CONF_ENABLED], 

310 CONF_ARCHIVE_PATH: file_section[CONF_ARCHIVE_PATH], 

311 CONF_ARCHIVE_DAYS: file_section[CONF_ARCHIVE_DAYS], 

312 CONF_ARCHIVE_PURGE_INTERVAL: file_section[CONF_ARCHIVE_PURGE_INTERVAL], 

313 CONF_ARCHIVE_MQTT_TOPIC: mqtt_section[CONF_ARCHIVE_MQTT_TOPIC], 

314 CONF_ARCHIVE_MQTT_QOS: mqtt_section[CONF_ARCHIVE_MQTT_QOS], 

315 CONF_ARCHIVE_MQTT_RETAIN: mqtt_section[CONF_ARCHIVE_MQTT_RETAIN], 

316 CONF_ARCHIVE_EVENT_NAME: event_section[CONF_ARCHIVE_EVENT_NAME], 

317 CONF_ARCHIVE_EVENT_SELECTION: _event_policy_from_list(event_section[CONF_ARCHIVE_EVENT_SELECTION]), 

318 CONF_ARCHIVE_DIAGNOSTICS: _event_policy_from_list(event_section[CONF_ARCHIVE_DIAGNOSTICS]), 

319 } 

320 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_ARCHIVE: processed}) 

321 outcome_selector = SelectSelector( 

322 SelectSelectorConfig(options=_OUTCOME_OPTIONS, multiple=True, translation_key="outcome_selection") 

323 ) 

324 schema = vol.Schema({ 

325 vol.Optional(CONF_ENABLED, default=False): cv.boolean, 

326 # section wrapper keys must be vol.Required, not vol.Optional: with Optional, the 

327 # frontend silently ignores both default and suggested_value for everything inside 

328 # (and for the rest of the form too) - https://github.com/home-assistant/frontend/issues/22419 

329 # No default= here - matches the working pattern in this repo's other integrations 

330 # (autoarm, remote_logger). 

331 vol.Required("file"): section( 

332 vol.Schema({ 

333 # cv.string, not cv.path: cv.path fails voluptuous-serialize schema 

334 # conversion used by the config flow frontend ("Unable to convert 

335 # schema" / HTTP 500). 

336 vol.Optional(CONF_ARCHIVE_PATH, default=ARCHIVE_DIR): cv.string, 

337 vol.Optional(CONF_ARCHIVE_DAYS, default=3): cv.positive_int, 

338 vol.Optional(CONF_ARCHIVE_PURGE_INTERVAL, default=60): cv.positive_int, 

339 }), 

340 {"collapsed": False}, 

341 ), 

342 vol.Required("mqtt"): section( 

343 vol.Schema({ 

344 vol.Optional(CONF_ARCHIVE_MQTT_TOPIC, default=""): cv.string, 

345 vol.Optional(CONF_ARCHIVE_MQTT_QOS, default=0): cv.positive_int, 

346 vol.Optional(CONF_ARCHIVE_MQTT_RETAIN, default=True): cv.boolean, 

347 }), 

348 {"collapsed": True}, 

349 ), 

350 vol.Required("event"): section( 

351 vol.Schema({ 

352 vol.Optional(CONF_ARCHIVE_EVENT_NAME, default="supernotification"): cv.string, 

353 vol.Optional(CONF_ARCHIVE_EVENT_SELECTION, default=[]): outcome_selector, 

354 vol.Optional(CONF_ARCHIVE_DIAGNOSTICS, default=[]): outcome_selector, 

355 }), 

356 {"collapsed": True}, 

357 ), 

358 }) 

359 # A plain vol.Optional(default=...) only sets the server-side validation fallback - 

360 # once a section is present, the frontend needs description.suggested_value (for 

361 # every field, not just the section-nested ones) to pre-fill an existing entry's 

362 # current values. 

363 suggested_values = { 

364 CONF_ENABLED: current.get(CONF_ENABLED, False), 

365 "file": { 

366 CONF_ARCHIVE_PATH: current.get(CONF_ARCHIVE_PATH, ARCHIVE_DIR), 

367 CONF_ARCHIVE_DAYS: current.get(CONF_ARCHIVE_DAYS, 3), 

368 CONF_ARCHIVE_PURGE_INTERVAL: current.get(CONF_ARCHIVE_PURGE_INTERVAL, 60), 

369 }, 

370 "mqtt": { 

371 CONF_ARCHIVE_MQTT_TOPIC: current.get(CONF_ARCHIVE_MQTT_TOPIC, ""), 

372 CONF_ARCHIVE_MQTT_QOS: current.get(CONF_ARCHIVE_MQTT_QOS, 0), 

373 CONF_ARCHIVE_MQTT_RETAIN: current.get(CONF_ARCHIVE_MQTT_RETAIN, True), 

374 }, 

375 "event": { 

376 CONF_ARCHIVE_EVENT_NAME: current.get(CONF_ARCHIVE_EVENT_NAME, "supernotification"), 

377 CONF_ARCHIVE_EVENT_SELECTION: _event_policy_to_list(current.get(CONF_ARCHIVE_EVENT_SELECTION, "NONE")), 

378 CONF_ARCHIVE_DIAGNOSTICS: _event_policy_to_list(current.get(CONF_ARCHIVE_DIAGNOSTICS, "ERROR")), 

379 }, 

380 } 

381 schema = self.add_suggested_values_to_schema(schema, suggested_values) 

382 return self.async_show_form( 

383 step_id="archive", data_schema=schema, description_placeholders={"learn_more_url": ARCHIVE_DOCS_URL} 

384 ) 

385 

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

387 current: dict[str, Any] = self.config_entry.options.get(CONF_DUPE_CHECK, {}) 

388 if user_input is not None: 

389 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_DUPE_CHECK: user_input}) 

390 schema = vol.Schema({ 

391 vol.Optional(CONF_TTL, default=current.get(CONF_TTL, 120)): cv.positive_int, 

392 vol.Optional(CONF_SIZE, default=current.get(CONF_SIZE, 100)): cv.positive_int, 

393 vol.Optional(CONF_DUPE_POLICY, default=current.get(CONF_DUPE_POLICY, ATTR_DUPE_POLICY_MTSLP)): SelectSelector( 

394 SelectSelectorConfig(options=_DUPE_POLICIES, translation_key="dupe_policy") 

395 ), 

396 }) 

397 return self.async_show_form( 

398 step_id="dupe_check", data_schema=schema, description_placeholders={"learn_more_url": DUPE_CHECK_DOCS_URL} 

399 ) 

400 

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

402 current: dict[str, Any] = self.config_entry.options.get(CONF_HOUSEKEEPING, {}) 

403 if user_input is not None: 

404 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_HOUSEKEEPING: user_input}) 

405 housekeeping_time = current.get(CONF_HOUSEKEEPING_TIME, "00:00:01") 

406 if not isinstance(housekeeping_time, str): 

407 # a YAML-imported entry may still have a raw datetime.time from before this was fixed 

408 housekeeping_time = housekeeping_time.isoformat() 

409 schema = vol.Schema({ 

410 vol.Optional(CONF_HOUSEKEEPING_TIME, default=housekeeping_time): cv.string, 

411 vol.Optional(CONF_MEDIA_STORAGE_DAYS, default=current.get(CONF_MEDIA_STORAGE_DAYS, 7)): cv.positive_int, 

412 }) 

413 return self.async_show_form(step_id="housekeeping", data_schema=schema) 

414 

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

416 """Defaults for deliveries that don't set their own - inclusion, and occupancy for spoken 

417 deliveries""" 

418 current: dict[str, Any] = self.config_entry.options.get(CONF_DELIVERY_CONTROL, {}) 

419 if user_input is not None: 

420 control: dict[str, Any] = {} 

421 if user_input[CONF_DEFAULT_INCLUSION] != NOT_SET_INCLUSION: 

422 control[CONF_DEFAULT_INCLUSION] = user_input[CONF_DEFAULT_INCLUSION] 

423 if user_input[CONF_VOICE_OCCUPANCY] != NOT_SET_OCCUPANCY: 

424 control[CONF_VOICE_OCCUPANCY] = user_input[CONF_VOICE_OCCUPANCY] 

425 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_DELIVERY_CONTROL: control}) 

426 schema = vol.Schema({ 

427 vol.Optional( 

428 CONF_DEFAULT_INCLUSION, default=current.get(CONF_DEFAULT_INCLUSION, NOT_SET_INCLUSION) 

429 ): SelectSelector( 

430 SelectSelectorConfig( 

431 options=[NOT_SET_INCLUSION, *DEFAULT_INCLUSION_VALUES], translation_key=CONF_DEFAULT_INCLUSION 

432 ) 

433 ), 

434 vol.Optional(CONF_VOICE_OCCUPANCY, default=current.get(CONF_VOICE_OCCUPANCY, NOT_SET_OCCUPANCY)): SelectSelector( 

435 SelectSelectorConfig(options=[NOT_SET_OCCUPANCY, *OCCUPANCY_VALUES], translation_key=CONF_VOICE_OCCUPANCY) 

436 ), 

437 }) 

438 return self.async_show_form(step_id="delivery_control", data_schema=schema) 

439 

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

441 """Beta: which tools llm.py offers to AI conversation agents and the MCP server, and whether 

442 sentences.py registers commands with the built-in agent""" 

443 current: dict[str, Any] = self.config_entry.options.get(CONF_LLM_TOOLS, {}) 

444 if user_input is not None: 

445 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_LLM_TOOLS: user_input}) 

446 schema = vol.Schema({ 

447 vol.Optional(CONF_LLM_ACTION_TOOLS, default=current.get(CONF_LLM_ACTION_TOOLS, False)): cv.boolean, 

448 vol.Optional(CONF_LLM_DIAGNOSTIC_TOOLS, default=current.get(CONF_LLM_DIAGNOSTIC_TOOLS, False)): cv.boolean, 

449 vol.Optional(CONF_SENTENCE_COMMANDS, default=current.get(CONF_SENTENCE_COMMANDS, False)): cv.boolean, 

450 }) 

451 return self.async_show_form(step_id="llm_tools", data_schema=schema)