diff --git a/CODEOWNERS b/CODEOWNERS index c630db7948..73f731b008 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -124,6 +124,7 @@ esphome/components/esp8266/* @esphome/core esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat esphome/components/exposure_notifications/* @OttoWinter +esphome/components/extensions/* @kpfleming esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov diff --git a/esphome/components/extensions/__init__.py b/esphome/components/extensions/__init__.py new file mode 100644 index 0000000000..334b5f1647 --- /dev/null +++ b/esphome/components/extensions/__init__.py @@ -0,0 +1,85 @@ +from typing import Any, Union +import esphome.config_validation as cv +from esphome.config_helpers import merge_config +from esphome.const import ( + CONF_EXTENSIONS, + CONF_ID, +) + +CODEOWNERS = ["@kpfleming"] + +DOMAIN = CONF_EXTENSIONS + +CONFIG_SCHEMA = cv.Schema( + { + cv.validate_id_name: dict, + } +) + + +class ObjectInDict: + def __init__(self, dic: dict, key: str): + self.dic = dic + self.key = key + + +class ObjectInList: + def __init__(self, lis: list, index: int): + self.lis = lis + self.index = index + + +def gather_object_ids(config: dict) -> dict[str, Union[ObjectInDict, ObjectInList]]: + result = {} + for k, v in config.items(): + if k == CONF_EXTENSIONS: + continue + if isinstance(v, dict): + if CONF_ID in v and isinstance(v[CONF_ID], str): + result[v[CONF_ID]] = ObjectInDict(config, k) + result.update(gather_object_ids(v)) + elif isinstance(v, list): + for i, o in enumerate(v): + if isinstance(o, dict): + if CONF_ID in o and isinstance(o[CONF_ID], str): + result[o[CONF_ID]] = ObjectInList(v, i) + result.update(gather_object_ids(o)) + return result + + +def do_extensions_pass(config: dict) -> dict[str, Any]: + if CONF_EXTENSIONS not in config: + return config + extensions = config[CONF_EXTENSIONS] + with cv.prepend_path(CONF_EXTENSIONS): + extensions = CONFIG_SCHEMA(extensions) + + while extensions: + matched = False + ids = gather_object_ids(config) + + for id in [id for id in extensions.keys() if id in ids]: + matched = True + obj = ids[id] + if isinstance(obj, ObjectInDict): + obj.dic[obj.key] = merge_config(obj.dic[obj.key], extensions[id]) + elif isinstance(obj, ObjectInList): + obj.lis[obj.index] = merge_config( + obj.lis[obj.index], extensions[id] + ) + del extensions[id] + + if not matched: + break + + # if any extension IDs were not found, report them + if extensions: + raise cv.MultipleInvalid( + [ + cv.Invalid(f"No match for extension ID {k} found in configuration.") + for k in extensions.keys() + ] + ) + + del config[CONF_EXTENSIONS] + return config diff --git a/esphome/config.py b/esphome/config.py index 36a81f677b..621c34fabc 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -16,6 +16,7 @@ from esphome import core, yaml_util, loader, pins import esphome.core.config as core_config from esphome.const import ( CONF_ESPHOME, + CONF_EXTENSIONS, CONF_ID, CONF_PLATFORM, CONF_PACKAGES, @@ -801,6 +802,8 @@ def validate_config( except vol.Invalid as err: result.add_error(err) + CORE.raw_config = config + # 1.2. Load external_components if CONF_EXTERNAL_COMPONENTS in config: from esphome.components.external_components import do_external_components_pass @@ -813,6 +816,20 @@ def validate_config( result.add_error(err) return result + # 2. Process object extensions + if CONF_EXTENSIONS in config: + from esphome.components.extensions import do_extensions_pass + + result.add_output_path([CONF_EXTENSIONS], CONF_EXTENSIONS) + try: + config = do_extensions_pass(config) + except vol.Invalid as err: + result.update(config) + result.add_error(err) + return result + + CORE.raw_config = config + if "esphomeyaml" in config: _LOGGER.warning( "The esphomeyaml section has been renamed to esphome in 1.11.0. " diff --git a/esphome/const.py b/esphome/const.py index 324b32e847..25e436a1ab 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -257,6 +257,7 @@ CONF_EVENT_TYPES = "event_types" CONF_EXPIRE_AFTER = "expire_after" CONF_EXPORT_ACTIVE_ENERGY = "export_active_energy" CONF_EXPORT_REACTIVE_ENERGY = "export_reactive_energy" +CONF_EXTENSIONS = "extensions" CONF_EXTERNAL_CLOCK_INPUT = "external_clock_input" CONF_EXTERNAL_COMPONENTS = "external_components" CONF_EXTERNAL_TEMPERATURE = "external_temperature" diff --git a/tests/component_tests/extensions/test_extensions.py b/tests/component_tests/extensions/test_extensions.py new file mode 100644 index 0000000000..65480cad6b --- /dev/null +++ b/tests/component_tests/extensions/test_extensions.py @@ -0,0 +1,174 @@ +"""Tests for the extensions component.""" + +import pytest + +import esphome.config_validation as cv + +from esphome.const import ( + CONF_EXTENSIONS, + CONF_ID, + CONF_NAME, + CONF_SENSOR, +) +from esphome.components.extensions import do_extensions_pass + +# Test strings +TEST_OBJECT_NAME_1 = "test_object_name_1" +TEST_SENSOR_NAME_1 = "test_sensor_name_1" +TEST_SENSOR_NAME_2 = "test_sensor_name_2" +TEST_SENSOR_ID_1 = "test_sensor_id_1" +TEST_SENSOR_ID_2 = "test_sensor_id_2" + + +def test_basic(): + """ + Ensure that the name provided by the extension is added to the object. + """ + config = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + } + ], + CONF_EXTENSIONS: { + TEST_SENSOR_ID_1: { + CONF_NAME: TEST_SENSOR_NAME_1, + } + }, + } + + expected = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + CONF_NAME: TEST_SENSOR_NAME_1, + } + ], + } + + actual = do_extensions_pass(config) + assert actual == expected + + +def test_basic_override(): + """ + Ensure that the name provided by the extension overrides the one in the object. + """ + config = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + CONF_NAME: TEST_SENSOR_NAME_1, + } + ], + CONF_EXTENSIONS: { + TEST_SENSOR_ID_1: { + CONF_NAME: TEST_SENSOR_NAME_2, + } + }, + } + + expected = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + CONF_NAME: TEST_SENSOR_NAME_2, + } + ], + } + + actual = do_extensions_pass(config) + assert actual == expected + + +def test_embedded(): + """ + Ensure that the name provided by the extension is added to the embedded object. + """ + config = { + CONF_SENSOR: [ + { + TEST_OBJECT_NAME_1: { + CONF_ID: TEST_SENSOR_ID_1, + }, + } + ], + CONF_EXTENSIONS: { + TEST_SENSOR_ID_1: { + CONF_NAME: TEST_SENSOR_NAME_1, + } + }, + } + + expected = { + CONF_SENSOR: [ + { + TEST_OBJECT_NAME_1: { + CONF_ID: TEST_SENSOR_ID_1, + CONF_NAME: TEST_SENSOR_NAME_1, + }, + } + ], + } + + actual = do_extensions_pass(config) + assert actual == expected + + +def test_two_pass_embedded(): + """ + Ensure that the name provided by an extension is added to the object added by a previous extension. + """ + config = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + } + ], + CONF_EXTENSIONS: { + TEST_SENSOR_ID_1: { + TEST_OBJECT_NAME_1: { + CONF_ID: TEST_SENSOR_ID_2, + }, + }, + TEST_SENSOR_ID_2: { + CONF_NAME: TEST_SENSOR_NAME_2, + }, + }, + } + + expected = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + TEST_OBJECT_NAME_1: { + CONF_ID: TEST_SENSOR_ID_2, + CONF_NAME: TEST_SENSOR_NAME_2, + }, + } + ], + } + + actual = do_extensions_pass(config) + assert actual == expected + + +def test_not_found(): + """ + Ensure that a non-matching ID is reported. + """ + config = { + CONF_SENSOR: [ + { + CONF_ID: TEST_SENSOR_ID_1, + } + ], + CONF_EXTENSIONS: { + TEST_SENSOR_ID_2: { + CONF_NAME: TEST_SENSOR_NAME_1, + } + }, + } + + with pytest.raises(cv.MultipleInvalid, match=f"extension ID {TEST_SENSOR_ID_2}"): + do_extensions_pass(config) diff --git a/tests/test_extensions.yaml b/tests/test_extensions.yaml new file mode 100644 index 0000000000..538e62b62a --- /dev/null +++ b/tests/test_extensions.yaml @@ -0,0 +1,47 @@ +--- +substitutions: + devicename: extensions + sensorname: my + textname: template + roomname: living_room + +esphome: + name: extensions + platform: ESP32 + board: nodemcu-32s + build_path: build/extensions + +i2c: + sda: + number: 21 + scl: + number: 22 + id: i2c_bus + +sensor: + - id: sensor_1 + platform: template + + - id: ${roomname} + platform: template + + - <<: !include test_packages/test_uptime_sensor.yaml + + - platform: bmp280 + temperature: + id: temperature + name: Outside Temperature + pressure: + name: Outside Pressure + address: 0x77 + i2c_id: i2c_bus + +extensions: + sensor_1: + name: ${sensorname} + ${roomname}: + name: sensor_name + ${devicename}_uptime_pcg: + update_interval: 10min + temperature: + oversampling: 8x