This commit is contained in:
Kevin P. Fleming 2024-05-02 15:46:55 +12:00 committed by GitHub
commit 52100291e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 325 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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. "

View File

@ -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"

View File

@ -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)

View File

@ -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