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
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 14:29 +0000
1"""Config flow for the Supernotify integration.
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"""
12from __future__ import annotations
14import logging
15from typing import Any
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
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
64_LOGGER = logging.getLogger(__name__)
66_DUPE_POLICIES = [ATTR_DUPE_POLICY_MTSLP, ATTR_DUPE_POLICY_MT, ATTR_DUPE_POLICY_NONE]
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/"
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"
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.
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)
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]
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("|")]
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"
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.
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])
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()
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
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).
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 }
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 })
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
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] = {}
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)
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)
225 return errors
228class SupernotifyConfigFlow(ConfigFlow, domain=DOMAIN):
229 """Handle a Supernotify config flow."""
231 VERSION = 1
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)
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.
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)
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.
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).
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 }
280 options = extract_legacy_options(import_data)
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)
289 @staticmethod
290 def async_get_options_flow(config_entry: ConfigEntry) -> SupernotifyOptionsFlow:
291 return SupernotifyOptionsFlow()
294class SupernotifyOptionsFlow(OptionsFlow):
295 """Options pages for archive, dupe_check, housekeeping and LLM tools settings."""
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 )
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 )
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 )
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)
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)
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)