Coverage for custom_components/supernotify/config_flow.py: 98%
422 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +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, and a
5subentry per recipient. Delivery, transports, scenarios, scenario_control, cameras,
6action_groups and snooze stay YAML-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 TYPE_CHECKING, Any
17from anyio import Path
18from homeassistant.config_entries import (
19 SOURCE_RECONFIGURE,
20 ConfigEntry,
21 ConfigFlow,
22 ConfigFlowResult,
23 ConfigSubentryFlow,
24 OptionsFlow,
25 SubentryFlowResult,
26)
27from homeassistant.const import (
28 ATTR_AREA_ID,
29 ATTR_DEVICE_ID,
30 ATTR_ENTITY_ID,
31 ATTR_FLOOR_ID,
32 ATTR_LABEL_ID,
33 CONF_ACTION,
34 CONF_ALIAS,
35 CONF_DEBUG,
36 CONF_EMAIL,
37 CONF_ENABLED,
38 CONF_HOST,
39 CONF_NAME,
40 CONF_PASSWORD,
41 CONF_PORT,
42 CONF_TARGET,
43 CONF_TIMEOUT,
44 CONF_USERNAME,
45 CONF_VERIFY_SSL,
46)
47from homeassistant.core import callback
48from homeassistant.data_entry_flow import section
49from homeassistant.helpers import config_validation as cv
50from homeassistant.helpers.selector import (
51 EntitySelector,
52 EntitySelectorConfig,
53 NumberSelector,
54 NumberSelectorConfig,
55 NumberSelectorMode,
56 ObjectSelector,
57 SelectOptionDict,
58 SelectSelector,
59 SelectSelectorConfig,
60 TargetSelector,
61 TextSelector,
62 TextSelectorConfig,
63 TextSelectorType,
64)
65from homeassistant.util import slugify
67from . import ARCHIVE_DIR, DOMAIN, MEDIA_DIR, TEMPLATE_DIR
68from .compat import vol
69from .const import (
70 ATTR_CUSTOM_TARGET,
71 ATTR_DUPE_POLICY_MT,
72 ATTR_DUPE_POLICY_MTSLP,
73 ATTR_DUPE_POLICY_NONE,
74 ATTR_EMAIL,
75 ATTR_PHONE,
76 CONF_ARCHIVE,
77 CONF_ARCHIVE_DAYS,
78 CONF_ARCHIVE_DIAGNOSTICS,
79 CONF_ARCHIVE_EVENT_NAME,
80 CONF_ARCHIVE_EVENT_SELECTION,
81 CONF_ARCHIVE_MQTT_QOS,
82 CONF_ARCHIVE_MQTT_RETAIN,
83 CONF_ARCHIVE_MQTT_TOPIC,
84 CONF_ARCHIVE_PATH,
85 CONF_CONNECTION,
86 CONF_DATA,
87 CONF_DEFAULT_INCLUSION,
88 CONF_DELIVERY,
89 CONF_DELIVERY_CONTROL,
90 CONF_DELIVERY_DEFAULTS,
91 CONF_DEPRECATED_ENTITIES,
92 CONF_DEVICE_DISCOVERY,
93 CONF_DEVICE_DOMAIN,
94 CONF_DEVICE_MODEL_EXCLUDE,
95 CONF_DEVICE_MODEL_INCLUDE,
96 CONF_DUPE_CHECK,
97 CONF_DUPE_POLICY,
98 CONF_ENCRYPTION,
99 CONF_HOUSEKEEPING,
100 CONF_HOUSEKEEPING_TIME,
101 CONF_INCLUSION,
102 CONF_LLM_ACTION_TOOLS,
103 CONF_LLM_DIAGNOSTIC_TOOLS,
104 CONF_LLM_TOOLS,
105 CONF_LOAD,
106 CONF_MEDIA_PATH,
107 CONF_MEDIA_STORAGE_DAYS,
108 CONF_MEDIA_URL_PREFIX,
109 CONF_MOBILE_APP_ID,
110 CONF_MOBILE_DEVICES,
111 CONF_MOBILE_DISCOVERY,
112 CONF_OPTIONS,
113 CONF_PERSON,
114 CONF_PHONE_NUMBER,
115 CONF_PRIORITY,
116 CONF_RECIPIENTS_DISCOVERY,
117 CONF_SELECTION,
118 CONF_SENTENCE_COMMANDS,
119 CONF_SIZE,
120 CONF_TARGET_USAGE,
121 CONF_TEMPLATE_PATH,
122 CONF_TRANSPORT,
123 CONF_TTL,
124 CONF_USER_ID,
125 CONF_VOICE_OCCUPANCY,
126 DEFAULT_INCLUSION_VALUES,
127 INCLUSION_VALUES,
128 OCCUPANCY_VALUES,
129 PRIORITY_VALUES,
130 SUBENTRY_TYPE_RECIPIENT,
131 SUBENTRY_TYPE_TRANSPORT,
132 TARGET_CATEGORY_VALUES,
133 TARGET_USAGE_VALUES,
134 TRANSPORT_GENERIC,
135 recipient_subentry_title,
136 transport_subentry_title,
137)
138from .hass_api import HomeAssistantAPI
139from .options import (
140 OPTION_DEVICE_DISCOVERY,
141 OPTION_DEVICE_DOMAIN,
142 OPTION_DEVICE_MODEL_SELECT,
143 SELECT_EXCLUDE,
144 SELECT_INCLUDE,
145)
146from .people import PeopleRegistry, recipient_key
147from .schema import RECIPIENT_DELIVERY_SCHEMA, RECIPIENT_SCHEMA, TRANSPORT_SCHEMA, OutcomeSelection
148from .target import Target
150if TYPE_CHECKING:
151 from .transport import Transport
153_LOGGER = logging.getLogger(__name__)
155# the recipient form's collapsible section holding its delivery overrides
156SECTION_DELIVERY_OVERRIDES = "delivery_overrides"
158_DUPE_POLICIES = [ATTR_DUPE_POLICY_MTSLP, ATTR_DUPE_POLICY_MT, ATTR_DUPE_POLICY_NONE]
160# Linked from the options pages that have their own documentation page - the "?" help icon can only
161# link to the manifest's one documentation URL
162ARCHIVE_DOCS_URL = "https://supernotify.rhizomatics.org.uk/latest/configuration/archiving/"
163DUPE_CHECK_DOCS_URL = "https://supernotify.rhizomatics.org.uk/latest/configuration/dupe_detection/"
164PEOPLE_DOCS_URL = "https://supernotify.rhizomatics.org.uk/latest/configuration/people/"
165TRANSPORTS_DOCS_URL = "https://supernotify.rhizomatics.org.uk/latest/transports/"
166# the transport form's field for the delivery defaults it has no field of its own for, and
167# those it does
168ATTR_OTHER_DELIVERY_DEFAULTS = "other_delivery_defaults"
169_TYPED_DELIVERY_DEFAULTS = (
170 CONF_DEBUG,
171 CONF_ACTION,
172 CONF_TARGET_USAGE,
173 CONF_OPTIONS,
174 CONF_DATA,
175 CONF_INCLUSION,
176 CONF_PRIORITY,
177)
178# the transport form's section for settings seldom changed, and the sections its delivery
179# defaults are split into
180SECTION_ADVANCED = "advanced"
181SECTION_DELIVERY_DEFAULTS_TARGETS = "delivery_defaults_targets"
182SECTION_DELIVERY_DEFAULTS_ADVANCED = "delivery_defaults_advanced"
183_DELIVERY_DEFAULTS_SECTIONS = (CONF_DELIVERY_DEFAULTS, SECTION_DELIVERY_DEFAULTS_TARGETS, SECTION_DELIVERY_DEFAULTS_ADVANCED)
185# The Delivery Control choices meaning "not set", which leave deliveries with their transport's own
186# default - never stored, the option is left out instead
187NOT_SET_INCLUSION = "transport"
188NOT_SET_OCCUPANCY = "not_controlled"
191def _event_policy_str(value: Any) -> str: # ruff: ignore[any-type]
192 """Render an OutcomeSelection as the pipe-separated name string parse_event_policy
193 expects (e.g. "ERROR|DUPE"), whatever form it currently happens to be in.
195 A YAML-imported archive config already went through ARCHIVE_SCHEMA, which turns
196 event_selection/diagnostics into OutcomeSelection (IntFlag) instances - stored as a raw
197 int once round-tripped through config-entry storage. Left unconverted, that raw int would
198 show up as a bare number in this form instead of the "ERROR"-style text it's meant to be.
199 """
200 if isinstance(value, str):
201 return value
202 if isinstance(value, int):
203 try:
204 return OutcomeSelection(value).name or "NONE"
205 except ValueError:
206 return "NONE"
207 return str(value)
210# NONE isn't a real, independently selectable outcome - it's the empty bitmask, and an
211# always-false no-op check in archive.py (outcome_policy & OutcomeSelection.NONE is always
212# 0). "No outcomes ticked" already means NONE, so it's excluded from the checkbox list.
213# Option values are lowercased since HA selector translation keys must match [a-z0-9-_]+ -
214# the stored/parsed policy strings stay uppercase (OutcomeSelection member names).
215_OUTCOME_OPTIONS = [flag.name.lower() for flag in OutcomeSelection if flag != OutcomeSelection.NONE and flag.name is not None]
218def _event_policy_to_list(value: str) -> list[str]:
219 """Turn a stored OutcomeSelection value into the list of names a multi-select needs."""
220 policy_str = _event_policy_str(value)
221 return [] if policy_str in ("", "NONE") else [part.lower() for part in policy_str.split("|")]
224def _event_policy_from_list(values: list[str]) -> str:
225 """Turn a submitted multi-select list back into the pipe-separated string
226 ARCHIVE_SCHEMA's parse_event_policy expects."""
227 return "|".join(value.upper() for value in values) if values else "NONE"
230def extract_legacy_options(import_data: dict[str, Any]) -> dict[str, Any]:
231 """Pull the archive/dupe_check/housekeeping option blocks out of a legacy YAML config dict,
232 normalizing archive's event_selection/diagnostics the same way a fresh entry would.
234 Shared by async_step_import (fresh entry bootstrap) and repairs.py's migration flow, which
235 also needs this when merging legacy config into an entry that already exists - e.g. one
236 auto-bootstrapped blank by async_setup before the interactive repair ever runs (see
237 repairs.py's async_sync_entry_from_legacy_config).
238 """
239 archive: dict[str, Any] = dict(import_data.get(CONF_ARCHIVE) or {})
240 if CONF_ARCHIVE_EVENT_SELECTION in archive:
241 archive[CONF_ARCHIVE_EVENT_SELECTION] = _event_policy_str(archive[CONF_ARCHIVE_EVENT_SELECTION])
242 if CONF_ARCHIVE_DIAGNOSTICS in archive:
243 archive[CONF_ARCHIVE_DIAGNOSTICS] = _event_policy_str(archive[CONF_ARCHIVE_DIAGNOSTICS])
245 housekeeping: dict[str, Any] = dict(import_data.get(CONF_HOUSEKEEPING) or {})
246 housekeeping_time = housekeeping.get(CONF_HOUSEKEEPING_TIME)
247 if housekeeping_time is not None and not isinstance(housekeeping_time, str):
248 # cv.time on the YAML side already coerced this into a datetime.time
249 housekeeping[CONF_HOUSEKEEPING_TIME] = housekeeping_time.isoformat()
251 options: dict[str, Any] = {}
252 if archive:
253 options[CONF_ARCHIVE] = archive
254 if import_data.get(CONF_DUPE_CHECK):
255 options[CONF_DUPE_CHECK] = import_data[CONF_DUPE_CHECK]
256 if housekeeping:
257 options[CONF_HOUSEKEEPING] = housekeeping
258 return options
261def extract_legacy_data(import_data: dict[str, Any]) -> dict[str, Any]:
262 """Pull the template_path/media_path/media_url_prefix/mobile_discovery/recipients_discovery
263 settings out of a legacy YAML config dict, defaulting anything not set - the same defaults a
264 fresh entry would get. Deliberately excludes `name`, which repairs.py's
265 async_sync_entry_from_legacy_config merges in separately (it isn't defaulted the same way).
267 Shared by async_step_import (fresh entry bootstrap) and repairs.py's migration flow, which
268 also needs this when merging legacy config into an entry that already exists - e.g. one
269 auto-bootstrapped blank by async_setup before the interactive repair ever runs (see
270 repairs.py's async_sync_entry_from_legacy_config). Without this, a "simple" install with
271 nothing that needs a repair (no delivery/transports/scenarios/etc to move into
272 supernotify.yaml) would silently keep running on defaults forever, ignoring a customized
273 template_path/media_path/etc in the legacy block.
274 """
275 return {
276 CONF_TEMPLATE_PATH: import_data.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR),
277 CONF_MEDIA_PATH: import_data.get(CONF_MEDIA_PATH, MEDIA_DIR),
278 CONF_MEDIA_URL_PREFIX: import_data.get(CONF_MEDIA_URL_PREFIX, "/supernotify/media"),
279 CONF_MOBILE_DISCOVERY: import_data.get(CONF_MOBILE_DISCOVERY, True),
280 CONF_RECIPIENTS_DISCOVERY: import_data.get(CONF_RECIPIENTS_DISCOVERY, True),
281 }
284def _user_schema(defaults: dict[str, Any] | None = None) -> vol.Schema:
285 # cv.string, not cv.path: cv.path fails voluptuous-serialize schema conversion used by
286 # the config flow frontend ("Unable to convert schema" / HTTP 500).
287 defaults = defaults or {}
288 return vol.Schema({
289 # Determines the registered notify.<name> action (slugified) - matches the legacy
290 # notify platform's `name:` YAML field, which this replaces as the sole source of truth.
291 vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME, DOMAIN)): cv.string,
292 vol.Optional(CONF_TEMPLATE_PATH, default=defaults.get(CONF_TEMPLATE_PATH, TEMPLATE_DIR)): cv.string,
293 vol.Optional(CONF_MEDIA_PATH, default=defaults.get(CONF_MEDIA_PATH, MEDIA_DIR)): cv.string,
294 vol.Optional(CONF_MEDIA_URL_PREFIX, default=defaults.get(CONF_MEDIA_URL_PREFIX, "/supernotify/media")): cv.string,
295 vol.Optional(CONF_MOBILE_DISCOVERY, default=defaults.get(CONF_MOBILE_DISCOVERY, True)): cv.boolean,
296 vol.Optional(CONF_RECIPIENTS_DISCOVERY, default=defaults.get(CONF_RECIPIENTS_DISCOVERY, True)): cv.boolean,
297 })
300async def _ensure_directory_exists(path_str: str) -> str | None:
301 """Create a directory if it doesn't exist yet, mirroring MediaStorage.initialize()'s own
302 tolerant runtime behavior (media_grab.py). Returns an error description on a genuine
303 failure to create/access it, None on success."""
304 try:
305 path = Path(path_str)
306 if not path.is_absolute():
307 path = await path.absolute()
308 if not await path.exists():
309 await path.mkdir(parents=True, exist_ok=True)
310 except (OSError, ValueError) as err:
311 return str(err)
312 return None
315async def _validate_user_input(user_input: dict[str, Any]) -> dict[str, str]:
316 """Validate template_path/media_path at config-flow submission time, creating each
317 directory if it doesn't exist yet. Catching a genuine failure here surfaces a typo'd or
318 unwritable path immediately in the wizard, instead of leaving the user to discover it
319 later via a repair issue. A blank value is left untouched - both fields are optional.
320 """
321 errors: dict[str, str] = {}
323 template_path = user_input.get(CONF_TEMPLATE_PATH)
324 if template_path:
325 error = await _ensure_directory_exists(template_path)
326 if error:
327 errors[CONF_TEMPLATE_PATH] = "template_path_invalid"
328 _LOGGER.debug("SUPERNOTIFY Invalid template_path %s: %s", template_path, error)
330 media_path = user_input.get(CONF_MEDIA_PATH)
331 if media_path:
332 error = await _ensure_directory_exists(media_path)
333 if error:
334 errors[CONF_MEDIA_PATH] = "media_path_invalid"
335 _LOGGER.debug("SUPERNOTIFY Invalid media_path %s: %s", media_path, error)
337 return errors
340class SupernotifyConfigFlow(ConfigFlow, domain=DOMAIN):
341 """Handle a Supernotify config flow."""
343 VERSION = 1
345 async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
346 errors: dict[str, str] = {}
347 if user_input is not None:
348 errors = await _validate_user_input(user_input)
349 if not errors:
350 return self.async_create_entry(title="Supernotify", data=user_input)
351 return self.async_show_form(step_id="user", data_schema=_user_schema(user_input), errors=errors)
353 async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
354 """Let the user change the global settings of an existing entry.
356 These fields are config-entry *data* (set at initial setup), not options, so HA's
357 guidance is a reconfigure step rather than the options flow - which instead covers
358 archive/dupe_check/housekeeping, genuine runtime preferences.
359 """
360 entry = self._get_reconfigure_entry()
361 errors: dict[str, str] = {}
362 if user_input is not None:
363 errors = await _validate_user_input(user_input)
364 if not errors:
365 # async_update_and_abort, not async_update_reload_and_abort: async_setup_entry
366 # registers an update listener, which async_update_entry already fires (and
367 # reloads via) whenever entry.data changes - calling the "_reload" variant too
368 # would reload twice and log an HA deprecation warning about the redundancy.
369 return self.async_update_and_abort(entry, data_updates=user_input)
370 defaults = user_input if user_input is not None else dict(entry.data)
371 return self.async_show_form(step_id="reconfigure", data_schema=_user_schema(defaults), errors=errors)
373 async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
374 """Bootstrap a config entry, optionally seeded from a legacy YAML config dict.
376 Two callers: __init__.py's async_setup (a from-scratch install with no entry yet) and
377 async_reload_yaml_config_and_entries's bootstrap, and repairs.py's migration flow (a
378 leftover legacy `notify: - platform: supernotify` block, possibly with real
379 archive/housekeeping/dupe_check/name/template_path/etc already set) - both pass
380 `data={}` for a genuinely fresh install, or the raw legacy config dict to preserve an
381 existing installation's settings (notably `name`, which determines the registered
382 notify.<name> action - see __init__.py's async_setup_entry).
384 Duplicate-entry protection is the same single_config_entry manifest flag used by the
385 user step (it covers SOURCE_IMPORT too), so no unique_id bookkeeping is needed here.
386 """
387 data: dict[str, Any] = {
388 CONF_NAME: import_data.get(CONF_NAME, DOMAIN),
389 **extract_legacy_data(import_data),
390 }
392 options = extract_legacy_options(import_data)
394 _LOGGER.info(
395 "SUPERNOTIFY Config entry bootstrapped (data=%s,options=%s)",
396 ";".join(data.keys()),
397 ";".join(options.keys()),
398 )
399 return self.async_create_entry(title="Supernotify", data=data, options=options)
401 @staticmethod
402 def async_get_options_flow(config_entry: ConfigEntry) -> SupernotifyOptionsFlow:
403 return SupernotifyOptionsFlow()
405 @classmethod
406 @callback
407 def async_get_supported_subentry_types(cls, config_entry: ConfigEntry) -> dict[str, type[ConfigSubentryFlow]]:
408 return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlow, SUBENTRY_TYPE_TRANSPORT: TransportSubentryFlow}
411class SupernotifyOptionsFlow(OptionsFlow):
412 """Options pages for archive, dupe_check, housekeeping and LLM tools settings."""
414 async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
415 return self.async_show_menu(
416 step_id="init", menu_options=["archive", "dupe_check", "housekeeping", "delivery_control", "llm_tools"]
417 )
419 async def async_step_archive(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
420 current: dict[str, Any] = self.config_entry.options.get(CONF_ARCHIVE, {})
421 if user_input is not None:
422 file_section = user_input["file"]
423 mqtt_section = user_input["mqtt"]
424 event_section = user_input["event"]
425 processed = {
426 CONF_ENABLED: user_input[CONF_ENABLED],
427 CONF_ARCHIVE_PATH: file_section[CONF_ARCHIVE_PATH],
428 CONF_ARCHIVE_DAYS: file_section[CONF_ARCHIVE_DAYS],
429 CONF_ARCHIVE_MQTT_TOPIC: mqtt_section[CONF_ARCHIVE_MQTT_TOPIC],
430 CONF_ARCHIVE_MQTT_QOS: mqtt_section[CONF_ARCHIVE_MQTT_QOS],
431 CONF_ARCHIVE_MQTT_RETAIN: mqtt_section[CONF_ARCHIVE_MQTT_RETAIN],
432 CONF_ARCHIVE_EVENT_NAME: event_section[CONF_ARCHIVE_EVENT_NAME],
433 CONF_ARCHIVE_EVENT_SELECTION: _event_policy_from_list(event_section[CONF_ARCHIVE_EVENT_SELECTION]),
434 CONF_ARCHIVE_DIAGNOSTICS: _event_policy_from_list(event_section[CONF_ARCHIVE_DIAGNOSTICS]),
435 }
436 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_ARCHIVE: processed})
437 outcome_selector = SelectSelector(
438 SelectSelectorConfig(options=_OUTCOME_OPTIONS, multiple=True, translation_key="outcome_selection")
439 )
440 schema = vol.Schema({
441 vol.Optional(CONF_ENABLED, default=False): cv.boolean,
442 # section wrapper keys must be vol.Required, not vol.Optional: with Optional, the
443 # frontend silently ignores both default and suggested_value for everything inside
444 # (and for the rest of the form too) - https://github.com/home-assistant/frontend/issues/22419
445 # No default= here - matches the working pattern in this repo's other integrations
446 # (autoarm, remote_logger).
447 vol.Required("file"): section(
448 vol.Schema({
449 # cv.string, not cv.path: cv.path fails voluptuous-serialize schema
450 # conversion used by the config flow frontend ("Unable to convert
451 # schema" / HTTP 500).
452 vol.Optional(CONF_ARCHIVE_PATH, default=ARCHIVE_DIR): cv.string,
453 vol.Optional(CONF_ARCHIVE_DAYS, default=3): cv.positive_int,
454 }),
455 {"collapsed": False},
456 ),
457 vol.Required("mqtt"): section(
458 vol.Schema({
459 vol.Optional(CONF_ARCHIVE_MQTT_TOPIC, default=""): cv.string,
460 vol.Optional(CONF_ARCHIVE_MQTT_QOS, default=0): cv.positive_int,
461 vol.Optional(CONF_ARCHIVE_MQTT_RETAIN, default=False): cv.boolean,
462 }),
463 {"collapsed": True},
464 ),
465 vol.Required("event"): section(
466 vol.Schema({
467 vol.Optional(CONF_ARCHIVE_EVENT_NAME, default="supernotification"): cv.string,
468 vol.Optional(CONF_ARCHIVE_EVENT_SELECTION, default=[]): outcome_selector,
469 vol.Optional(CONF_ARCHIVE_DIAGNOSTICS, default=[]): outcome_selector,
470 }),
471 {"collapsed": True},
472 ),
473 })
474 # A plain vol.Optional(default=...) only sets the server-side validation fallback -
475 # once a section is present, the frontend needs description.suggested_value (for
476 # every field, not just the section-nested ones) to pre-fill an existing entry's
477 # current values.
478 suggested_values = {
479 CONF_ENABLED: current.get(CONF_ENABLED, False),
480 "file": {
481 CONF_ARCHIVE_PATH: current.get(CONF_ARCHIVE_PATH, ARCHIVE_DIR),
482 CONF_ARCHIVE_DAYS: current.get(CONF_ARCHIVE_DAYS, 3),
483 },
484 "mqtt": {
485 CONF_ARCHIVE_MQTT_TOPIC: current.get(CONF_ARCHIVE_MQTT_TOPIC, ""),
486 CONF_ARCHIVE_MQTT_QOS: current.get(CONF_ARCHIVE_MQTT_QOS, 0),
487 CONF_ARCHIVE_MQTT_RETAIN: current.get(CONF_ARCHIVE_MQTT_RETAIN, False),
488 },
489 "event": {
490 CONF_ARCHIVE_EVENT_NAME: current.get(CONF_ARCHIVE_EVENT_NAME, "supernotification"),
491 CONF_ARCHIVE_EVENT_SELECTION: _event_policy_to_list(current.get(CONF_ARCHIVE_EVENT_SELECTION, "NONE")),
492 CONF_ARCHIVE_DIAGNOSTICS: _event_policy_to_list(current.get(CONF_ARCHIVE_DIAGNOSTICS, "ERROR")),
493 },
494 }
495 schema = self.add_suggested_values_to_schema(schema, suggested_values)
496 return self.async_show_form(
497 step_id="archive", data_schema=schema, description_placeholders={"learn_more_url": ARCHIVE_DOCS_URL}
498 )
500 async def async_step_dupe_check(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
501 current: dict[str, Any] = self.config_entry.options.get(CONF_DUPE_CHECK, {})
502 if user_input is not None:
503 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_DUPE_CHECK: user_input})
504 schema = vol.Schema({
505 vol.Optional(CONF_TTL, default=current.get(CONF_TTL, 120)): cv.positive_int,
506 vol.Optional(CONF_SIZE, default=current.get(CONF_SIZE, 100)): cv.positive_int,
507 vol.Optional(CONF_DUPE_POLICY, default=current.get(CONF_DUPE_POLICY, ATTR_DUPE_POLICY_MTSLP)): SelectSelector(
508 SelectSelectorConfig(options=_DUPE_POLICIES, translation_key="dupe_policy")
509 ),
510 })
511 return self.async_show_form(
512 step_id="dupe_check", data_schema=schema, description_placeholders={"learn_more_url": DUPE_CHECK_DOCS_URL}
513 )
515 async def async_step_housekeeping(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
516 current: dict[str, Any] = self.config_entry.options.get(CONF_HOUSEKEEPING, {})
517 if user_input is not None:
518 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_HOUSEKEEPING: user_input})
519 housekeeping_time = current.get(CONF_HOUSEKEEPING_TIME, "00:00:01")
520 if not isinstance(housekeeping_time, str):
521 # a YAML-imported entry may still have a raw datetime.time from before this was fixed
522 housekeeping_time = housekeeping_time.isoformat()
523 schema = vol.Schema({
524 vol.Optional(CONF_HOUSEKEEPING_TIME, default=housekeeping_time): cv.string,
525 vol.Optional(CONF_MEDIA_STORAGE_DAYS, default=current.get(CONF_MEDIA_STORAGE_DAYS, 7)): cv.positive_int,
526 })
527 # only offered while there are some, carried over from an earlier version
528 from .binary_sensor import deprecated_entity_ids
530 if deprecated_entity_ids(self.hass, self.config_entry.entry_id):
531 schema = schema.extend({
532 vol.Optional(CONF_DEPRECATED_ENTITIES, default=current.get(CONF_DEPRECATED_ENTITIES, True)): cv.boolean
533 })
534 return self.async_show_form(step_id="housekeeping", data_schema=schema)
536 async def async_step_delivery_control(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
537 """Defaults for deliveries that don't set their own - inclusion, and occupancy for spoken
538 deliveries"""
539 current: dict[str, Any] = self.config_entry.options.get(CONF_DELIVERY_CONTROL, {})
540 if user_input is not None:
541 control: dict[str, Any] = {}
542 if user_input[CONF_DEFAULT_INCLUSION] != NOT_SET_INCLUSION:
543 control[CONF_DEFAULT_INCLUSION] = user_input[CONF_DEFAULT_INCLUSION]
544 if user_input[CONF_VOICE_OCCUPANCY] != NOT_SET_OCCUPANCY:
545 control[CONF_VOICE_OCCUPANCY] = user_input[CONF_VOICE_OCCUPANCY]
546 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_DELIVERY_CONTROL: control})
547 schema = vol.Schema({
548 vol.Optional(
549 CONF_DEFAULT_INCLUSION, default=current.get(CONF_DEFAULT_INCLUSION, NOT_SET_INCLUSION)
550 ): SelectSelector(
551 SelectSelectorConfig(
552 options=[NOT_SET_INCLUSION, *DEFAULT_INCLUSION_VALUES], translation_key=CONF_DEFAULT_INCLUSION
553 )
554 ),
555 vol.Optional(CONF_VOICE_OCCUPANCY, default=current.get(CONF_VOICE_OCCUPANCY, NOT_SET_OCCUPANCY)): SelectSelector(
556 SelectSelectorConfig(options=[NOT_SET_OCCUPANCY, *OCCUPANCY_VALUES], translation_key=CONF_VOICE_OCCUPANCY)
557 ),
558 })
559 return self.async_show_form(step_id="delivery_control", data_schema=schema)
561 async def async_step_llm_tools(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
562 """Which tools llm.py offers to AI conversation agents and the MCP server, and whether
563 sentences.py registers commands with the built-in agent"""
564 current: dict[str, Any] = self.config_entry.options.get(CONF_LLM_TOOLS, {})
565 if user_input is not None:
566 return self.async_create_entry(title="", data={**self.config_entry.options, CONF_LLM_TOOLS: user_input})
567 schema = vol.Schema({
568 vol.Optional(CONF_LLM_ACTION_TOOLS, default=current.get(CONF_LLM_ACTION_TOOLS, False)): cv.boolean,
569 vol.Optional(CONF_LLM_DIAGNOSTIC_TOOLS, default=current.get(CONF_LLM_DIAGNOSTIC_TOOLS, False)): cv.boolean,
570 vol.Optional(CONF_SENTENCE_COMMANDS, default=current.get(CONF_SENTENCE_COMMANDS, False)): cv.boolean,
571 })
572 return self.async_show_form(step_id="llm_tools", data_schema=schema)
575# The target categories the target selector gives, as in supernotify.notify - anything else is
576# a custom target, entered as text
577_SELECTOR_TARGET_CATEGORIES = (ATTR_ENTITY_ID, ATTR_DEVICE_ID, ATTR_AREA_ID, ATTR_FLOOR_ID, ATTR_LABEL_ID)
578# Custom targets recognised without a "category:" prefix
579_PLAIN_CUSTOM_CATEGORIES = (ATTR_EMAIL, ATTR_PHONE)
582def target_from_fields(selected: dict[str, Any] | None, custom: list[str] | None) -> dict[str, list[str]]:
583 """Build a recipient target from the target selector and custom target fields, the same
584 pair supernotify.notify offers. A custom target is a "category:value", whose value passes the
585 category's own check where it's one of TARGET_CATEGORY_VALUES, or an e-mail address or phone
586 number on its own. Raises vol.Invalid for one that is neither."""
587 target: dict[str, list[str]] = {}
588 for category, values in (selected or {}).items():
589 if found := [str(v) for v in cv.ensure_list(values) if v]:
590 target[category] = found
591 for value in custom or []:
592 value = value.strip()
593 if not value:
594 continue
595 prefix, sep, rest = value.partition(":")
596 if sep and rest and not rest.startswith("/") and prefix == slugify(prefix):
597 category, value = prefix, rest
598 # a custom category, for a generic transport, is left as it is
599 if category in TARGET_CATEGORY_VALUES and (check := getattr(Target, f"is_{category}", None)) and not check(value):
600 raise vol.Invalid(f"not a valid {category}: {value}")
601 else:
602 category = next(iter(Target([value]).targets))
603 if category not in _PLAIN_CUSTOM_CATEGORIES:
604 raise vol.Invalid(f"unrecognised custom target {value}")
605 target.setdefault(category, [])
606 if value not in target[category]:
607 target[category].append(value)
608 return target
611def target_to_fields(target: Any) -> tuple[dict[str, list[str]], list[str]]: # ruff: ignore[any-type]
612 """Split a recipient target back into the target selector and custom target fields"""
613 as_dict: dict[str, list[str]] = Target(target).as_dict() if target else {}
614 selected: dict[str, list[str]] = {}
615 custom: list[str] = []
616 for category, values in as_dict.items():
617 if category in _SELECTOR_TARGET_CATEGORIES:
618 selected[category] = list(values)
619 elif category in _PLAIN_CUSTOM_CATEGORIES:
620 custom.extend(values)
621 else:
622 custom.extend(f"{category}:{v}" for v in values)
623 return selected, custom
626class RecipientSubentryFlow(ConfigSubentryFlow):
627 """Add or change a recipient - who, how to reach them, which of their mobile devices to
628 use, and how individual deliveries treat them.
630 The recipient is held in RECIPIENT_SCHEMA shape as the steps go, and saved from the menu.
631 A recipient adds to, or overrides, the one discovered for the same person or user.
632 """
634 def __init__(self) -> None:
635 self._recipient: dict[str, Any] = {}
636 # the new recipient's subentry unique_id, from recipient_key()
637 self._key: str | None = None
639 async def async_step_user(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
640 """Who the recipient is - a person, a user with no person, or by name, someone who isn't a
641 Home Assistant user. Those discovered are recipients already, so this is mostly for the last"""
642 errors: dict[str, str] = {}
643 if user_input is not None:
644 key = recipient_key(user_input)
645 if not key:
646 errors["base"] = "person_or_user_required"
647 elif any(s.unique_id == key for s in self._get_entry().subentries.values()):
648 return self.async_abort(reason="already_configured")
649 else:
650 self._key = key
651 self._recipient = {k: v for k, v in user_input.items() if v}
652 # a person's own user, which Home Assistant keeps on the person
653 if (person := self._recipient.get(CONF_PERSON)) and CONF_USER_ID not in self._recipient:
654 state = self.hass.states.get(person)
655 if state and isinstance(user_id := state.attributes.get(CONF_USER_ID), str):
656 self._recipient[CONF_USER_ID] = user_id
657 # only a Home Assistant user has mobile app devices
658 has_user = CONF_PERSON in self._recipient or CONF_USER_ID in self._recipient
659 self._recipient[CONF_MOBILE_DISCOVERY] = has_user and self._mobile_discovery_default()
660 return await self.async_step_details()
661 # only users with no person, since one with a person is chosen by the person
662 person_user_ids = {s.attributes.get(CONF_USER_ID) for s in self.hass.states.async_all("person")}
663 users = {k: v for k, v in (await HomeAssistantAPI(self.hass).async_real_user_ids()).items() if k not in person_user_ids}
664 fields: dict[Any, Any] = {vol.Optional(CONF_PERSON): EntitySelector(EntitySelectorConfig(domain="person"))}
665 if users:
666 fields[vol.Optional(CONF_USER_ID)] = SelectSelector(
667 SelectSelectorConfig(options=[SelectOptionDict(value=k, label=v) for k, v in users.items()])
668 )
669 fields[vol.Optional(CONF_ALIAS)] = TextSelector()
670 schema = vol.Schema(fields)
671 return self.async_show_form(
672 step_id="user", data_schema=schema, errors=errors, description_placeholders={"learn_more_url": PEOPLE_DOCS_URL}
673 )
675 async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
676 self._recipient = dict(self._get_reconfigure_subentry().data)
677 return await self.async_step_details()
679 async def async_step_details(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
680 """How to reach the recipient, and which of their mobile devices to use"""
681 errors: dict[str, str] = {}
682 devices = self._mobile_device_options()
683 if user_input is not None:
684 try:
685 target = target_from_fields(user_input.get(CONF_TARGET), user_input.get(ATTR_CUSTOM_TARGET))
686 except vol.Invalid:
687 errors[ATTR_CUSTOM_TARGET] = "invalid_custom_target"
688 try:
689 delivery = RECIPIENT_DELIVERY_SCHEMA(user_input.get(SECTION_DELIVERY_OVERRIDES, {}).get(CONF_DELIVERY) or {})
690 except vol.Invalid:
691 errors["base"] = "invalid_delivery"
692 if not errors:
693 # the alias is who someone that isn't a Home Assistant user is, so can't be left blank
694 has_user = CONF_PERSON in self._recipient or CONF_USER_ID in self._recipient
695 for key in (CONF_ALIAS, CONF_EMAIL, CONF_PHONE_NUMBER):
696 if user_input.get(key):
697 self._recipient[key] = user_input[key]
698 elif key != CONF_ALIAS or has_user:
699 self._recipient.pop(key, None)
700 self._recipient[CONF_ENABLED] = user_input[CONF_ENABLED]
701 self._recipient[CONF_MOBILE_DISCOVERY] = user_input[CONF_MOBILE_DISCOVERY]
702 for key, value in ((CONF_TARGET, target), (CONF_DELIVERY, delivery)):
703 if value:
704 self._recipient[key] = value
705 else:
706 self._recipient.pop(key, None)
707 selected = user_input.get(CONF_MOBILE_DEVICES, [])
708 self._recipient[CONF_MOBILE_DEVICES] = [
709 {CONF_MOBILE_APP_ID: d["value"], CONF_ENABLED: d["value"] in selected} for d in devices
710 ]
711 return await self.async_step_save()
713 selected_target, custom_target = target_to_fields(self._recipient.get(CONF_TARGET))
714 disabled = {
715 d.get(CONF_MOBILE_APP_ID) for d in self._recipient.get(CONF_MOBILE_DEVICES, []) if not d.get(CONF_ENABLED, True)
716 }
717 schema = vol.Schema({
718 vol.Optional(CONF_ALIAS): TextSelector(),
719 vol.Optional(CONF_EMAIL): TextSelector(TextSelectorConfig(type=TextSelectorType.EMAIL)),
720 vol.Optional(CONF_PHONE_NUMBER): TextSelector(TextSelectorConfig(type=TextSelectorType.TEL)),
721 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
722 vol.Optional(CONF_MOBILE_DISCOVERY, default=True): cv.boolean,
723 vol.Optional(CONF_MOBILE_DEVICES): SelectSelector(SelectSelectorConfig(options=devices, multiple=True)),
724 vol.Optional(CONF_TARGET): TargetSelector(),
725 vol.Optional(ATTR_CUSTOM_TARGET): TextSelector(TextSelectorConfig(multiple=True)),
726 vol.Optional(SECTION_DELIVERY_OVERRIDES): section(
727 vol.Schema({vol.Optional(CONF_DELIVERY): ObjectSelector()}),
728 {"collapsed": not (self._recipient.get(CONF_DELIVERY) or errors.get("base"))},
729 ),
730 })
731 suggested = {
732 CONF_ALIAS: self._recipient.get(CONF_ALIAS),
733 CONF_EMAIL: self._recipient.get(CONF_EMAIL),
734 CONF_PHONE_NUMBER: self._recipient.get(CONF_PHONE_NUMBER),
735 CONF_ENABLED: self._recipient.get(CONF_ENABLED, True),
736 CONF_MOBILE_DISCOVERY: self._recipient.get(CONF_MOBILE_DISCOVERY, self._mobile_discovery_default()),
737 CONF_MOBILE_DEVICES: [d["value"] for d in devices if d["value"] not in disabled],
738 CONF_TARGET: selected_target or None,
739 ATTR_CUSTOM_TARGET: custom_target or None,
740 SECTION_DELIVERY_OVERRIDES: {CONF_DELIVERY: self._recipient.get(CONF_DELIVERY) or None},
741 }
742 return self.async_show_form(
743 step_id="details",
744 data_schema=self.add_suggested_values_to_schema(schema, suggested),
745 errors=errors,
746 description_placeholders={
747 "recipient": self._title(),
748 "learn_more_url": PEOPLE_DOCS_URL,
749 "deliveries": ", ".join(self._delivery_names()) or "-",
750 "categories": ", ".join(TARGET_CATEGORY_VALUES),
751 },
752 )
754 async def async_step_save(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
755 RECIPIENT_SCHEMA(self._recipient) # anything the steps let through that it wouldn't is a bug here
756 if self.source == SOURCE_RECONFIGURE:
757 return self.async_update_and_abort(
758 self._get_entry(), self._get_reconfigure_subentry(), data=self._recipient, title=self._title()
759 )
760 return self.async_create_entry(
761 title=self._title(),
762 data=self._recipient,
763 unique_id=self._key,
764 )
766 def _mobile_discovery_default(self) -> bool:
767 """The integration's own setting, which a recipient without one of their own follows"""
768 return bool(self._get_entry().data.get(CONF_MOBILE_DISCOVERY, True))
770 def _title(self) -> str:
771 """The alias, else the person's name, else the user's, prefixed as a recipient"""
772 if alias := self._recipient.get(CONF_ALIAS):
773 return recipient_subentry_title(str(alias))
774 if (person := self._recipient.get(CONF_PERSON)) and (state := self.hass.states.get(person)):
775 return recipient_subentry_title(state.name)
776 return recipient_subentry_title(str(self._recipient.get(CONF_PERSON) or self._recipient.get(CONF_USER_ID)))
778 def _mobile_device_options(self) -> list[SelectOptionDict]:
779 """The recipient's discovered mobile devices that can be notified, and any already
780 configured for them that weren't discovered"""
781 hass_api = HomeAssistantAPI(self.hass)
782 hass_api.build_mobile_app_cache()
783 registry = PeopleRegistry([], hass_api)
784 user_id = self._recipient.get(CONF_USER_ID)
785 person = self._recipient.get(CONF_PERSON)
786 if user_id:
787 discovered = registry.mobile_devices_for_user(user_id)
788 elif person:
789 discovered = registry.mobile_devices_for_person(person)
790 else:
791 discovered = []
792 options = {
793 d.mobile_app_id: SelectOptionDict(value=d.mobile_app_id, label=d.device_name or d.mobile_app_id)
794 for d in discovered
795 if d.mobile_app_id and d.action
796 }
797 for d in self._recipient.get(CONF_MOBILE_DEVICES, []):
798 app_id = d.get(CONF_MOBILE_APP_ID)
799 if app_id and app_id not in options:
800 options[app_id] = SelectOptionDict(value=app_id, label=app_id)
801 return list(options.values())
803 def _delivery_names(self) -> list[str]:
804 """The running deliveries, to list for the delivery field"""
805 engine = getattr(self._get_entry(), "runtime_data", None)
806 return sorted(engine.context.delivery_registry.deliveries) if engine is not None else []
809class TransportSubentryFlow(ConfigSubentryFlow):
810 """Add or change a transport - whether it's used, and the defaults for its deliveries.
812 Every transport in use is listed already, so adding one is for those needing configuration
813 before they can be used, like generic's action or chime's aliases. The transport is held as
814 it would be written in YAML under `transports:`, and saved as that.
815 """
817 def __init__(self) -> None:
818 self._name: str = ""
819 self._transport: dict[str, Any] = {}
821 async def async_step_user(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
822 """Which transport, of those not listed already whose integration is here"""
823 addable = self._addable()
824 if not addable:
825 return self.async_abort(reason="no_transports")
826 if user_input is not None:
827 self._name = user_input[CONF_TRANSPORT]
828 return await self.async_step_details()
829 schema = vol.Schema({vol.Required(CONF_TRANSPORT): SelectSelector(SelectSelectorConfig(options=addable))})
830 return self.async_show_form(
831 step_id="user", data_schema=schema, description_placeholders={"learn_more_url": TRANSPORTS_DOCS_URL}
832 )
834 async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
835 subentry = self._get_reconfigure_subentry()
836 self._name = str(subentry.unique_id)
837 self._transport = dict(subentry.data)
838 return await self.async_step_details()
840 async def async_step_details(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
841 """Whether the transport is used, the defaults for its deliveries, and its connection"""
842 errors: dict[str, str] = {}
843 error = ""
844 # deprecated settings are shown, and saved, as their replacements
845 defaults: dict[str, Any] = dict(_migrate_deprecated(self._transport).get(CONF_DELIVERY_DEFAULTS) or {})
846 if user_input is not None:
847 # the delivery defaults, from the three sections they're split into
848 given: dict[str, Any] = {
849 k: v for name in _DELIVERY_DEFAULTS_SECTIONS for k, v in (user_input.get(name) or {}).items()
850 }
851 other = given.get(ATTR_OTHER_DELIVERY_DEFAULTS) or {}
852 try:
853 target = target_from_fields(given.get(CONF_TARGET), given.get(ATTR_CUSTOM_TARGET))
854 except vol.Invalid:
855 errors["base"] = "invalid_custom_target"
856 if not isinstance(other, dict):
857 errors["base"] = "invalid_transport"
858 error = str(other)
859 if not errors:
860 # a field left empty leaves the same setting in the other delivery defaults alone
861 defaults = dict(other)
862 for key in _TYPED_DELIVERY_DEFAULTS:
863 if given.get(key):
864 defaults[key] = given[key]
865 if target:
866 defaults[CONF_TARGET] = target
867 transport = {
868 k: v
869 for k, v in _migrate_deprecated(self._transport).items()
870 # an alias, from YAML, has no field, but is kept, as the transport device's name
871 if k not in (CONF_ENABLED, CONF_LOAD, CONF_DELIVERY_DEFAULTS, CONF_CONNECTION)
872 }
873 transport[CONF_ENABLED] = user_input[CONF_ENABLED]
874 transport[CONF_LOAD] = user_input.get(SECTION_ADVANCED, {}).get(CONF_LOAD, True)
875 for key, value in (
876 (CONF_DELIVERY_DEFAULTS, defaults),
877 (CONF_CONNECTION, self._connection(user_input.get(CONF_CONNECTION))),
878 ):
879 if value:
880 transport[key] = value
881 try:
882 TRANSPORT_SCHEMA(transport)
883 except vol.Invalid as err:
884 errors["base"] = "invalid_transport"
885 error = str(err)
886 else:
887 self._transport = transport
888 return await self.async_step_save()
890 selected_target, custom_target = target_to_fields(defaults.get(CONF_TARGET))
891 transport_class = self._transport_class()
892 fields: dict[Any, Any] = {
893 vol.Optional(CONF_ENABLED, default=True): cv.boolean,
894 }
895 main_fields: dict[Any, Any] = {vol.Optional(CONF_DEBUG): cv.boolean}
896 if self._takes_action():
897 main_fields[vol.Optional(CONF_ACTION)] = TextSelector()
898 main_fields.update({
899 vol.Optional(CONF_INCLUSION): SelectSelector(SelectSelectorConfig(options=INCLUSION_VALUES, multiple=True)),
900 # custom values, as YAML can give a priority as a number
901 vol.Optional(CONF_PRIORITY): SelectSelector(
902 SelectSelectorConfig(options=list(PRIORITY_VALUES), multiple=True, custom_value=True)
903 ),
904 })
905 target_fields: dict[Any, Any] = {
906 # above the targets, as it says how they're used
907 vol.Optional(CONF_TARGET_USAGE): SelectSelector(
908 SelectSelectorConfig(options=TARGET_USAGE_VALUES, translation_key=CONF_TARGET_USAGE)
909 ),
910 vol.Optional(CONF_TARGET): TargetSelector(),
911 vol.Optional(ATTR_CUSTOM_TARGET): TextSelector(TextSelectorConfig(multiple=True)),
912 }
913 yaml_fields: dict[Any, Any] = {
914 vol.Optional(CONF_OPTIONS): ObjectSelector(),
915 vol.Optional(CONF_DATA): ObjectSelector(),
916 vol.Optional(ATTR_OTHER_DELIVERY_DEFAULTS): ObjectSelector(),
917 }
918 # the connection first, where there is one, as nothing works without it
919 connection: dict[str, Any] = dict(self._transport.get(CONF_CONNECTION) or {})
920 if transport_class is not None and transport_class.uses_connection:
921 fields[vol.Optional(CONF_CONNECTION)] = section(
922 vol.Schema({
923 vol.Optional(CONF_HOST): TextSelector(),
924 vol.Optional(CONF_PORT): NumberSelector(
925 NumberSelectorConfig(min=1, max=65535, mode=NumberSelectorMode.BOX)
926 ),
927 vol.Optional(CONF_ENCRYPTION): SelectSelector(
928 SelectSelectorConfig(options=["tls", "starttls", "none"], translation_key="connection_encryption")
929 ),
930 vol.Optional(CONF_USERNAME): TextSelector(),
931 # never shown, and left empty to keep the one there is
932 vol.Optional(CONF_PASSWORD): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)),
933 vol.Optional(CONF_TIMEOUT): NumberSelector(NumberSelectorConfig(min=1, mode=NumberSelectorMode.BOX)),
934 vol.Optional(CONF_VERIFY_SSL): cv.boolean,
935 }),
936 {"collapsed": not connection},
937 )
938 suggested_targets = {
939 CONF_TARGET_USAGE: defaults.get(CONF_TARGET_USAGE),
940 CONF_TARGET: selected_target or None,
941 ATTR_CUSTOM_TARGET: custom_target or None,
942 }
943 suggested_yaml = {
944 CONF_OPTIONS: defaults.get(CONF_OPTIONS),
945 CONF_DATA: defaults.get(CONF_DATA),
946 ATTR_OTHER_DELIVERY_DEFAULTS: {
947 k: v for k, v in defaults.items() if k not in (*_TYPED_DELIVERY_DEFAULTS, CONF_TARGET)
948 }
949 or None,
950 }
951 # the targets and the YAML opened only when there's something set in them, or a problem
952 for name, section_fields, collapsed in (
953 (CONF_DELIVERY_DEFAULTS, main_fields, False),
954 (SECTION_DELIVERY_DEFAULTS_TARGETS, target_fields, not (any(suggested_targets.values()) or errors)),
955 (SECTION_DELIVERY_DEFAULTS_ADVANCED, yaml_fields, not (any(suggested_yaml.values()) or errors)),
956 ):
957 fields[vol.Optional(name)] = section(vol.Schema(section_fields), {"collapsed": collapsed})
958 load = self._transport.get(CONF_LOAD, True)
959 fields[vol.Optional(SECTION_ADVANCED)] = section(
960 vol.Schema({vol.Optional(CONF_LOAD, default=True): cv.boolean}), {"collapsed": bool(load)}
961 )
962 suggested = {
963 CONF_ENABLED: self._transport.get(CONF_ENABLED, True),
964 SECTION_ADVANCED: {CONF_LOAD: load},
965 CONF_DELIVERY_DEFAULTS: {
966 CONF_DEBUG: defaults.get(CONF_DEBUG, False),
967 CONF_ACTION: defaults.get(CONF_ACTION),
968 CONF_INCLUSION: cv.ensure_list(defaults.get(CONF_INCLUSION)) or None,
969 CONF_PRIORITY: [str(p) for p in cv.ensure_list(defaults.get(CONF_PRIORITY))] or None,
970 },
971 SECTION_DELIVERY_DEFAULTS_TARGETS: suggested_targets,
972 SECTION_DELIVERY_DEFAULTS_ADVANCED: suggested_yaml,
973 CONF_CONNECTION: {k: v for k, v in connection.items() if k != CONF_PASSWORD},
974 }
975 return self.async_show_form(
976 step_id="details",
977 data_schema=self.add_suggested_values_to_schema(vol.Schema(fields), suggested),
978 errors=errors,
979 description_placeholders={
980 "transport": self._title(),
981 "learn_more_url": f"{TRANSPORTS_DOCS_URL}{self._name}/",
982 "categories": ", ".join(TARGET_CATEGORY_VALUES),
983 "error": error,
984 },
985 )
987 def _connection(self, given: dict[str, Any] | None) -> dict[str, Any]:
988 """The connection as entered, the numbers as whole numbers, and with the password kept
989 when it's left empty"""
990 connection = {k: int(v) if isinstance(v, float) else v for k, v in (given or {}).items() if v not in (None, "")}
991 if CONF_PASSWORD not in connection and (password := (self._transport.get(CONF_CONNECTION) or {}).get(CONF_PASSWORD)):
992 connection[CONF_PASSWORD] = password
993 return connection
995 async def async_step_save(self, user_input: dict[str, Any] | None = None) -> SubentryFlowResult:
996 if self.source == SOURCE_RECONFIGURE:
997 return self.async_update_and_abort(
998 self._get_entry(), self._get_reconfigure_subentry(), data=self._transport, title=self._title()
999 )
1000 return self.async_create_entry(title=self._title(), data=self._transport, unique_id=self._name)
1002 def _title(self) -> str:
1003 return transport_subentry_title(self._name)
1005 def _transport_class(self) -> type[Transport] | None:
1006 from .static_config import TRANSPORTS
1008 return next((t for t in TRANSPORTS if t.name == self._name), None)
1010 def _takes_action(self) -> bool:
1011 """Whether the transport's action is configured, rather than fixed or found for it"""
1012 transport_class = self._transport_class()
1013 return transport_class is not None and (transport_class.action_configured or self._name == TRANSPORT_GENERIC)
1015 def _addable(self) -> list[str]:
1016 """The transports not listed already, whose integration, if they need one, is here"""
1017 from .static_config import TRANSPORTS
1019 listed = {s.unique_id for s in self._get_entry().subentries.values() if s.subentry_type == SUBENTRY_TYPE_TRANSPORT}
1020 hass_api = HomeAssistantAPI(self.hass)
1021 return sorted(
1022 t.name
1023 for t in TRANSPORTS
1024 if t.name not in listed and (t.integration_domain is None or hass_api.integration_loaded(t.integration_domain))
1025 )
1028def _migrate_deprecated(transport: dict[str, Any]) -> dict[str, Any]:
1029 """A transport's configuration with its deprecated settings moved to their replacements, as
1030 model.TransportConfig applies them - device_domain, device_discovery and device_model_include/
1031 exclude to its options, and its delivery defaults' selection to inclusion"""
1032 migrated = {
1033 k: v
1034 for k, v in transport.items()
1035 if k not in (CONF_DEVICE_DOMAIN, CONF_DEVICE_DISCOVERY, CONF_DEVICE_MODEL_INCLUDE, CONF_DEVICE_MODEL_EXCLUDE)
1036 }
1037 defaults: dict[str, Any] = dict(transport.get(CONF_DELIVERY_DEFAULTS) or {})
1038 options: dict[str, Any] = dict(defaults.get(CONF_OPTIONS) or {})
1039 if CONF_DEVICE_DOMAIN in transport:
1040 options[OPTION_DEVICE_DOMAIN] = transport[CONF_DEVICE_DOMAIN]
1041 if CONF_DEVICE_MODEL_INCLUDE in transport or CONF_DEVICE_MODEL_EXCLUDE in transport:
1042 options[OPTION_DEVICE_MODEL_SELECT] = {
1043 SELECT_INCLUDE: transport.get(CONF_DEVICE_MODEL_INCLUDE),
1044 SELECT_EXCLUDE: transport.get(CONF_DEVICE_MODEL_EXCLUDE),
1045 }
1046 if CONF_DEVICE_DISCOVERY in transport and options.get(OPTION_DEVICE_DISCOVERY) is None:
1047 options[OPTION_DEVICE_DISCOVERY] = transport[CONF_DEVICE_DISCOVERY]
1048 if CONF_SELECTION in defaults:
1049 selection = defaults.pop(CONF_SELECTION)
1050 defaults.setdefault(CONF_INCLUSION, selection)
1051 if options:
1052 defaults[CONF_OPTIONS] = options
1053 if defaults:
1054 migrated[CONF_DELIVERY_DEFAULTS] = defaults
1055 return migrated