1 """Module to help with parsing and generating configuration files."""
3 from __future__
import annotations
5 from collections
import OrderedDict
6 from collections.abc
import Sequence
7 from contextlib
import suppress
12 from typing
import TYPE_CHECKING, Any, Final
13 from urllib.parse
import urlparse
15 import voluptuous
as vol
16 from webrtc_models
import RTCConfiguration, RTCIceServer
20 from .auth
import mfa_modules
as auth_mfa_modules, providers
as auth_providers
26 CONF_ALLOWLIST_EXTERNAL_DIRS,
27 CONF_ALLOWLIST_EXTERNAL_URLS,
28 CONF_AUTH_MFA_MODULES,
33 CONF_CUSTOMIZE_DOMAIN,
42 CONF_LEGACY_TEMPLATES,
48 CONF_TEMPERATURE_UNIT,
54 EVENT_CORE_CONFIG_UPDATE,
55 LEGACY_CONF_WHITELIST_EXTERNAL_DIRS,
59 from .core
import DOMAIN
as HOMEASSISTANT_DOMAIN, HomeAssistant
60 from .generated.currencies
import HISTORIC_CURRENCIES
61 from .helpers
import config_validation
as cv, issue_registry
as ir
62 from .helpers.entity_values
import EntityValues
63 from .helpers.frame
import ReportBehavior, report_usage
64 from .helpers.storage
import Store
65 from .helpers.typing
import UNDEFINED, UndefinedType
66 from .util
import dt
as dt_util, location
67 from .util.hass_dict
import HassKey
68 from .util.package
import is_docker_env
69 from .util.unit_system
import (
70 _CONF_UNIT_SYSTEM_IMPERIAL,
71 _CONF_UNIT_SYSTEM_US_CUSTOMARY,
80 from .components.http
import ApiConfig
82 _LOGGER = logging.getLogger(__name__)
84 DATA_CUSTOMIZE: HassKey[EntityValues] =
HassKey(
"hass_customize")
86 CONF_CREDENTIAL: Final =
"credential"
87 CONF_ICE_SERVERS: Final =
"ice_servers"
88 CONF_WEBRTC: Final =
"webrtc"
90 CORE_STORAGE_KEY =
"core.config"
91 CORE_STORAGE_VERSION = 1
92 CORE_STORAGE_MINOR_VERSION = 4
96 """Source of core configuration."""
99 DISCOVERED =
"discovered"
105 configs: Sequence[dict[str, Any]],
106 ) -> Sequence[dict[str, Any]]:
107 """No duplicate auth provider config allowed in a list.
109 Each type of auth provider can only have one config without optional id.
110 Unique id is required if same type of auth provider used multiple times.
112 config_keys: set[tuple[str, str |
None]] = set()
113 for config
in configs:
114 key = (config[CONF_TYPE], config.get(CONF_ID))
115 if key
in config_keys:
117 f
"Duplicate auth provider {config[CONF_TYPE]} found. "
118 "Please add unique IDs "
119 "if you want to have the same auth provider twice"
126 configs: Sequence[dict[str, Any]],
127 ) -> Sequence[dict[str, Any]]:
128 """No duplicate auth mfa module item allowed in a list.
130 Each type of mfa module can only have one config without optional id.
131 A global unique id is required if same type of mfa module used multiple
133 Note: this is different than auth provider
135 config_keys: set[str] = set()
136 for config
in configs:
137 key = config.get(CONF_ID, config[CONF_TYPE])
138 if key
in config_keys:
140 f
"Duplicate mfa module {config[CONF_TYPE]} found. "
141 "Please add unique IDs "
142 "if you want to have the same mfa module twice"
149 """Filter internal/external URL with a path."""
150 for key
in CONF_INTERNAL_URL, CONF_EXTERNAL_URL:
151 if key
in conf
and urlparse(conf[key]).path
not in (
"",
"/"):
155 "Invalid %s set. It's not allowed to have a path (/bla)", key
162 _PACKAGES_CONFIG_SCHEMA = vol.Schema({cv.string: vol.Any(dict, list)})
165 _PACKAGE_DEFINITION_SCHEMA = vol.Schema({cv.string: vol.Any(dict, list,
None)})
167 _CUSTOMIZE_DICT_SCHEMA = vol.Schema(
169 vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
170 vol.Optional(ATTR_HIDDEN): cv.boolean,
171 vol.Optional(ATTR_ASSUMED_STATE): cv.boolean,
173 extra=vol.ALLOW_EXTRA,
176 _CUSTOMIZE_CONFIG_SCHEMA = vol.Schema(
178 vol.Optional(CONF_CUSTOMIZE, default={}): vol.Schema(
179 {cv.entity_id: _CUSTOMIZE_DICT_SCHEMA}
181 vol.Optional(CONF_CUSTOMIZE_DOMAIN, default={}): vol.Schema(
182 {cv.string: _CUSTOMIZE_DICT_SCHEMA}
184 vol.Optional(CONF_CUSTOMIZE_GLOB, default={}): vol.Schema(
185 {cv.string: _CUSTOMIZE_DICT_SCHEMA}
192 if currency
not in HISTORIC_CURRENCIES:
193 ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN,
"historic_currency")
196 ir.async_create_issue(
198 HOMEASSISTANT_DOMAIN,
201 learn_more_url=
"homeassistant://config/general",
202 severity=ir.IssueSeverity.WARNING,
203 translation_key=
"historic_currency",
204 translation_placeholders={
"currency": currency},
209 if country
is not None:
210 ir.async_delete_issue(hass, HOMEASSISTANT_DOMAIN,
"country_not_configured")
213 ir.async_create_issue(
215 HOMEASSISTANT_DOMAIN,
216 "country_not_configured",
218 learn_more_url=
"homeassistant://config/general",
219 severity=ir.IssueSeverity.WARNING,
220 translation_key=
"country_not_configured",
226 return cv.currency(data)
227 except vol.InInvalid:
228 with suppress(vol.InInvalid):
229 return cv.historic_currency(data)
234 """Validate an URL."""
236 url = urlparse(url_in)
238 if url.scheme
not in (
"stun",
"stuns",
"turn",
"turns"):
239 raise vol.Invalid(
"invalid url")
243 CORE_CONFIG_SCHEMA = vol.All(
244 _CUSTOMIZE_CONFIG_SCHEMA.extend(
246 CONF_NAME: vol.Coerce(str),
247 CONF_LATITUDE: cv.latitude,
248 CONF_LONGITUDE: cv.longitude,
249 CONF_ELEVATION: vol.Coerce(int),
250 CONF_RADIUS: cv.positive_int,
251 vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit,
252 CONF_UNIT_SYSTEM: validate_unit_system,
253 CONF_TIME_ZONE: cv.time_zone,
254 vol.Optional(CONF_INTERNAL_URL): cv.url,
255 vol.Optional(CONF_EXTERNAL_URL): cv.url,
256 vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All(
257 cv.ensure_list, [vol.IsDir()]
259 vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All(
260 cv.ensure_list, [vol.IsDir()]
262 vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All(
263 cv.ensure_list, [cv.url]
265 vol.Optional(CONF_PACKAGES, default={}): _PACKAGES_CONFIG_SCHEMA,
266 vol.Optional(CONF_AUTH_PROVIDERS): vol.All(
269 auth_providers.AUTH_PROVIDER_SCHEMA.extend(
271 CONF_TYPE: vol.NotIn(
272 [
"insecure_example"],
274 "The insecure_example auth provider"
275 " is for testing only."
281 _no_duplicate_auth_provider,
283 vol.Optional(CONF_AUTH_MFA_MODULES): vol.All(
286 auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend(
288 CONF_TYPE: vol.NotIn(
289 [
"insecure_example"],
290 "The insecure_example mfa module is for testing only.",
295 _no_duplicate_auth_mfa_module,
297 vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()),
298 vol.Remove(CONF_LEGACY_TEMPLATES): cv.boolean,
299 vol.Optional(CONF_CURRENCY): _validate_currency,
300 vol.Optional(CONF_COUNTRY): cv.country,
301 vol.Optional(CONF_LANGUAGE): cv.language,
302 vol.Optional(CONF_DEBUG): cv.boolean,
303 vol.Optional(CONF_WEBRTC): vol.Schema(
305 vol.Required(CONF_ICE_SERVERS): vol.All(
310 vol.Required(CONF_URL): vol.All(
311 cv.ensure_list, [_validate_stun_or_turn_url]
313 vol.Optional(CONF_USERNAME): cv.string,
314 vol.Optional(CONF_CREDENTIAL): cv.string,
323 _filter_bad_internal_external_urls,
328 """Process the [homeassistant] section from the configuration.
330 This method is a coroutine.
334 config = await hass.async_add_executor_job(CORE_CONFIG_SCHEMA, config)
337 if not hasattr(hass,
"auth"):
338 if (auth_conf := config.get(CONF_AUTH_PROVIDERS))
is None:
339 auth_conf = [{
"type":
"homeassistant"}]
341 mfa_conf = config.get(
342 CONF_AUTH_MFA_MODULES,
343 [{
"type":
"totp",
"id":
"totp",
"name":
"Authenticator app"}],
347 hass,
"auth", await auth.auth_manager_from_config(hass, auth_conf, mfa_conf)
350 await hass.config.async_load()
371 hac.config_source = ConfigSource.YAML
374 (CONF_COUNTRY,
"country"),
375 (CONF_CURRENCY,
"currency"),
376 (CONF_ELEVATION,
"elevation"),
377 (CONF_EXTERNAL_URL,
"external_url"),
378 (CONF_INTERNAL_URL,
"internal_url"),
379 (CONF_LANGUAGE,
"language"),
380 (CONF_LATITUDE,
"latitude"),
381 (CONF_LONGITUDE,
"longitude"),
382 (CONF_MEDIA_DIRS,
"media_dirs"),
383 (CONF_NAME,
"location_name"),
384 (CONF_RADIUS,
"radius"),
387 setattr(hac, attr, config[key])
389 if config.get(CONF_DEBUG):
392 if CONF_WEBRTC
in config:
393 hac.webrtc.ice_servers = [
396 server.get(CONF_USERNAME),
397 server.get(CONF_CREDENTIAL),
399 for server
in config[CONF_WEBRTC][CONF_ICE_SERVERS]
405 if CONF_TIME_ZONE
in config:
406 await hac.async_set_time_zone(config[CONF_TIME_ZONE])
408 if CONF_MEDIA_DIRS
not in config:
410 hac.media_dirs = {
"local":
"/media"}
412 hac.media_dirs = {
"local": hass.config.path(
"media")}
415 hac.allowlist_external_dirs = {hass.config.path(
"www"), *hac.media_dirs.values()}
416 if CONF_ALLOWLIST_EXTERNAL_DIRS
in config:
417 hac.allowlist_external_dirs.update(set(config[CONF_ALLOWLIST_EXTERNAL_DIRS]))
419 elif LEGACY_CONF_WHITELIST_EXTERNAL_DIRS
in config:
421 "Key %s has been replaced with %s. Please update your config",
422 LEGACY_CONF_WHITELIST_EXTERNAL_DIRS,
423 CONF_ALLOWLIST_EXTERNAL_DIRS,
425 hac.allowlist_external_dirs.update(
426 set(config[LEGACY_CONF_WHITELIST_EXTERNAL_DIRS])
431 if CONF_ALLOWLIST_EXTERNAL_URLS
in config:
432 hac.allowlist_external_urls.update(
433 url
if url.endswith(
"/")
else f
"{url}/"
434 for url
in config[CONF_ALLOWLIST_EXTERNAL_URLS]
438 cust_exact =
dict(config[CONF_CUSTOMIZE])
439 cust_domain =
dict(config[CONF_CUSTOMIZE_DOMAIN])
440 cust_glob = OrderedDict(config[CONF_CUSTOMIZE_GLOB])
442 for name, pkg
in config[CONF_PACKAGES].items():
443 if (pkg_cust := pkg.get(HOMEASSISTANT_DOMAIN))
is None:
449 _LOGGER.warning(
"Package %s contains invalid customize", name)
452 cust_exact.update(pkg_cust[CONF_CUSTOMIZE])
453 cust_domain.update(pkg_cust[CONF_CUSTOMIZE_DOMAIN])
454 cust_glob.update(pkg_cust[CONF_CUSTOMIZE_GLOB])
456 hass.data[DATA_CUSTOMIZE] =
EntityValues(cust_exact, cust_domain, cust_glob)
458 if CONF_UNIT_SYSTEM
in config:
463 """Set of loaded components.
465 This set contains both top level components and platforms.
468 `light`, `switch`, `hue`, `mjpeg.camera`, `universal.media_player`,
469 `homeassistant.scene`
471 The top level components set only contains the top level components.
473 The all components set contains all components, including platform
479 self, top_level_components: set[str], all_components: set[str]
481 """Initialize the component set."""
485 def add(self, component: str) ->
None:
486 """Add a component to the store."""
487 if "." not in component:
491 platform, _, domain = component.partition(
".")
492 if domain
in BASE_PLATFORMS:
494 return super().
add(component)
496 def remove(self, component: str) ->
None:
497 """Remove a component from the store."""
499 raise ValueError(
"_ComponentSet does not support removing sub-components")
501 return super().
remove(component)
504 """Remove a component from the store."""
505 raise NotImplementedError(
"_ComponentSet does not support discard, use remove")
509 """Configuration settings for Home Assistant."""
513 def __init__(self, hass: HomeAssistant, config_dir: str) ->
None:
514 """Initialize a new config object."""
516 from .components.zone
import DEFAULT_RADIUS
524 """Elevation (always in meters regardless of the unit system)."""
526 self.
radiusradius: int = DEFAULT_RADIUS
527 """Radius of the Home Zone (always in meters regardless of the unit system)."""
529 self.debug: bool =
False
532 self.
unitsunits: UnitSystem = METRIC_SYSTEM
536 self.
countrycountry: str |
None =
None
539 self.
config_sourceconfig_source: ConfigSource = ConfigSource.DEFAULT
542 self.skip_pip: bool =
False
545 self.skip_pip_packages: list[str] = []
550 self.top_level_components: set[str] = set()
554 self.all_components: set[str] = set()
558 self.top_level_components, self.all_components
562 self.api: ApiConfig |
None =
None
565 self.config_dir: str = config_dir
568 self.allowlist_external_dirs: set[str] = set()
571 self.allowlist_external_urls: set[str] = set()
574 self.media_dirs: dict[str, str] = {}
577 self.recovery_mode: bool =
False
580 self.legacy_templates: bool =
False
583 self.safe_mode: bool =
False
588 """Finish initializing a config object.
590 This must be called before the config object is used.
594 def distance(self, lat: float, lon: float) -> float |
None:
595 """Calculate distance from Home Assistant.
599 return self.
unitsunits.length(
604 def path(self, *path: str) -> str:
605 """Generate path to the file within the configuration directory.
609 return os.path.join(self.config_dir, *path)
612 """Check if an external URL is allowed."""
613 parsed_url = f
"{yarl.URL(url)!s}/"
617 for allowed
in self.allowlist_external_urls
618 if parsed_url.startswith(allowed)
622 """Check if the path is valid for access from outside.
624 This function does blocking I/O and should not be called from the event loop.
625 Use hass.async_add_executor_job to schedule it on the executor.
627 assert path
is not None
629 thepath = pathlib.Path(path)
633 thepath = thepath.resolve()
635 thepath = thepath.parent.resolve()
636 except (FileNotFoundError, RuntimeError, PermissionError):
639 for allowed_path
in self.allowlist_external_dirs:
641 thepath.relative_to(allowed_path)
650 """Return a dictionary representation of the configuration.
654 allowlist_external_dirs =
list(self.allowlist_external_dirs)
656 "allowlist_external_dirs": allowlist_external_dirs,
657 "allowlist_external_urls":
list(self.allowlist_external_urls),
658 "components":
list(self.components),
659 "config_dir": self.config_dir,
661 "country": self.
countrycountry,
671 "radius": self.
radiusradius,
672 "recovery_mode": self.recovery_mode,
673 "safe_mode": self.safe_mode,
674 "state": self.
hasshass.state.value,
677 "version": __version__,
679 "whitelist_external_dirs": allowlist_external_dirs,
683 """Help to set the time zone."""
684 if time_zone := await dt_util.async_get_time_zone(time_zone_str):
686 dt_util.set_default_time_zone(time_zone)
688 raise ValueError(f
"Received invalid time zone {time_zone_str}")
691 """Set the time zone.
693 This is a legacy method that should not be used in new code.
694 Use async_set_time_zone instead.
696 It will be removed in Home Assistant 2025.6.
699 "sets the time zone using set_time_zone instead of async_set_time_zone",
700 core_integration_behavior=ReportBehavior.ERROR,
701 custom_integration_behavior=ReportBehavior.ERROR,
702 breaks_in_ha_version=
"2025.6",
704 if time_zone := dt_util.get_time_zone(time_zone_str):
706 dt_util.set_default_time_zone(time_zone)
708 raise ValueError(f
"Received invalid time zone {time_zone_str}")
713 country: str | UndefinedType |
None = UNDEFINED,
714 currency: str |
None =
None,
715 elevation: int |
None =
None,
716 external_url: str | UndefinedType |
None = UNDEFINED,
717 internal_url: str | UndefinedType |
None = UNDEFINED,
718 language: str |
None =
None,
719 latitude: float |
None =
None,
720 location_name: str |
None =
None,
721 longitude: float |
None =
None,
722 radius: int |
None =
None,
723 source: ConfigSource,
724 time_zone: str |
None =
None,
725 unit_system: str |
None =
None,
727 """Update the configuration from a dictionary."""
729 if country
is not UNDEFINED:
731 if currency
is not None:
733 if elevation
is not None:
735 if external_url
is not UNDEFINED:
737 if internal_url
is not UNDEFINED:
739 if language
is not None:
741 if latitude
is not None:
743 if location_name
is not None:
745 if longitude
is not None:
747 if radius
is not None:
749 if time_zone
is not None:
751 if unit_system
is not None:
755 self.
unitsunits = METRIC_SYSTEM
758 """Update the configuration from a dictionary."""
759 await self.
_async_update_async_update(source=ConfigSource.STORAGE, **kwargs)
761 self.
hasshass.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE, kwargs)
767 """Load [homeassistant] core config."""
773 if data.get(
"external_url")
and urlparse(data[
"external_url"]).path
not in (
777 _LOGGER.warning(
"Invalid external_url set. It's not allowed to have a path")
779 if data.get(
"internal_url")
and urlparse(data[
"internal_url"]).path
not in (
783 _LOGGER.warning(
"Invalid internal_url set. It's not allowed to have a path")
786 source=ConfigSource.STORAGE,
787 latitude=data.get(
"latitude"),
788 longitude=data.get(
"longitude"),
789 elevation=data.get(
"elevation"),
790 unit_system=data.get(
"unit_system_v2"),
791 location_name=data.get(
"location_name"),
792 time_zone=data.get(
"time_zone"),
793 external_url=data.get(
"external_url", UNDEFINED),
794 internal_url=data.get(
"internal_url", UNDEFINED),
795 currency=data.get(
"currency"),
796 country=data.get(
"country"),
797 language=data.get(
"language"),
798 radius=data[
"radius"],
802 """Store [homeassistant] core config."""
809 "unit_system_v2": self.
unitsunits._name,
815 "country": self.
countrycountry,
817 "radius": self.
radiusradius,
822 """Class to help storing Config data."""
825 """Initialize storage class."""
828 CORE_STORAGE_VERSION,
832 minor_version=CORE_STORAGE_MINOR_VERSION,
838 old_major_version: int,
839 old_minor_version: int,
840 old_data: dict[str, Any],
842 """Migrate to the new version."""
845 from .components.zone
import DEFAULT_RADIUS
848 if old_major_version == 1
and old_minor_version < 2:
853 if data[
"unit_system_v2"] == _CONF_UNIT_SYSTEM_IMPERIAL:
854 data[
"unit_system_v2"] = _CONF_UNIT_SYSTEM_US_CUSTOMARY
855 if old_major_version == 1
and old_minor_version < 3:
858 data[
"language"] =
"en"
860 owner = await self.hass.auth.async_get_owner()
861 if owner
is not None:
863 from .components.frontend
import storage
as frontend_store
865 _, owner_data = await frontend_store.async_user_store(
870 "language" in owner_data
871 and "language" in owner_data[
"language"]
873 with suppress(vol.InInvalid):
874 data[
"language"] = cv.language(
875 owner_data[
"language"][
"language"]
879 _LOGGER.exception(
"Unexpected error during core config migration")
880 if old_major_version == 1
and old_minor_version < 4:
882 data.setdefault(
"radius", DEFAULT_RADIUS)
884 if old_major_version > 1:
885 raise NotImplementedError
dict[str, Any] _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, Any] old_data)
None async_save(self, dict[str, Any] data)
None __init__(self, HomeAssistant hass)
None async_update(self, **Any kwargs)
None async_initialize(self)
None _async_update(self, *str|UndefinedType|None country=UNDEFINED, str|None currency=None, int|None elevation=None, str|UndefinedType|None external_url=UNDEFINED, str|UndefinedType|None internal_url=UNDEFINED, str|None language=None, float|None latitude=None, str|None location_name=None, float|None longitude=None, int|None radius=None, ConfigSource source, str|None time_zone=None, str|None unit_system=None)
float|None distance(self, float lat, float lon)
None async_set_time_zone(self, str time_zone_str)
None set_time_zone(self, str time_zone_str)
None __init__(self, HomeAssistant hass, str config_dir)
str path(self, *str path)
bool is_allowed_path(self, str path)
bool is_allowed_external_url(self, str url)
dict[str, Any] as_dict(self)
None __init__(self, set[str] top_level_components, set[str] all_components)
None discard(self, str component)
None remove(self, str component)
None add(self, str component)
str _validate_stun_or_turn_url(Any value)
Sequence[dict[str, Any]] _no_duplicate_auth_mfa_module(Sequence[dict[str, Any]] configs)
None _raise_issue_if_no_country(HomeAssistant hass, str|None country)
None _raise_issue_if_historic_currency(HomeAssistant hass, str currency)
None async_process_ha_core_config(HomeAssistant hass, dict config)
Any _validate_currency(Any data)
Sequence[dict[str, Any]] _no_duplicate_auth_provider(Sequence[dict[str, Any]] configs)
dict _filter_bad_internal_external_urls(dict conf)
None report_usage(str what, *str|None breaks_in_ha_version=None, ReportBehavior core_behavior=ReportBehavior.ERROR, ReportBehavior core_integration_behavior=ReportBehavior.LOG, ReportBehavior custom_integration_behavior=ReportBehavior.LOG, set[str]|None exclude_integrations=None, str|None integration_domain=None, int level=logging.WARNING)
None async_save(self, _T data)
UnitSystem get_unit_system(str key)