Files

192 lines
5.6 KiB
Python

from collections.abc import Callable
import difflib
import esphome.codegen as cg
from esphome.components.const import KEY_METADATA
import esphome.config_validation as cv
from esphome.const import CONF_FROM, CONF_ID, CONF_TO
from esphome.core import CORE, ID
from esphome.cpp_generator import (
MockObj,
MockObjClass,
VariableDeclarationExpression,
add_global,
)
from esphome.loader import get_component
CODEOWNERS = ["@clydebarrow"]
MULTI_CONF = True
DOMAIN = "mapping"
mapping_ns = cg.esphome_ns.namespace("mapping")
mapping_class = mapping_ns.class_("Mapping")
CONF_DEFAULT_VALUE = "default_value"
CONF_ENTRIES = "entries"
CONF_CLASS = "class"
class IndexType:
"""
Represents a type of index in a map.
"""
def __init__(
self, validator: Callable, data_type: MockObj, conversion: Callable = None
) -> None:
self.validator = validator
self.data_type = data_type
self.conversion = conversion
async def convert_value(self, value):
if self.conversion:
return self.conversion(value)
return await cg.get_variable(value)
INDEX_TYPES = {
"int": IndexType(cv.int_, cg.int_, int),
"string": IndexType(
cv.string,
cg.std_string,
str,
),
}
class MappingMetaData:
def __init__(self, from_: IndexType, to_: IndexType) -> None:
self.from_ = from_
self.to_ = to_
def to_schema(value):
"""
Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name.
:param value:
:return:
"""
return cv.Any(
cv.one_of(*INDEX_TYPES, lower=True),
cv.one_of(*CORE.id_classes.keys()),
)(value)
BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(mapping_class),
cv.Required(CONF_FROM): cv.one_of(*INDEX_TYPES, lower=True),
cv.Required(CONF_TO): cv.string,
},
extra=cv.ALLOW_EXTRA,
)
def get_object_type(to_) -> MockObjClass | None:
"""
Get the object type from a string. Possible formats:
xxx The name of a component which defines INSTANCE_TYPE
esphome::xxx::yyy A C++ class name defined in a component
xxx::yyy A C++ class name defined in a component
yyy A C++ class name defined in the core
"""
if cls := CORE.id_classes.get(to_):
return cls
if cls := CORE.id_classes.get(to_.removeprefix("esphome::")):
return cls
# get_component will throw a wobbly if we don't check this first.
if "." in to_:
return None
if component := get_component(to_):
return component.instance_type
return None
def get_all_mapping_metadata() -> dict[str, MappingMetaData]:
"""Get all mapping metadata."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
def get_mapping_metadata(mapping_id: str) -> MappingMetaData:
"""Get mapping metadata by ID for use by other components."""
return get_all_mapping_metadata()[mapping_id]
def add_metadata(
mapping_id: ID,
from_: IndexType,
to_: IndexType,
) -> None:
get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_)
def map_schema(config):
config = BASE_SCHEMA(config)
if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict):
raise cv.Invalid("an entries dictionary is required for a mapping")
entries = config[CONF_ENTRIES]
if len(entries) == 0:
raise cv.Invalid("A mapping must have at least one entry")
to_ = config[CONF_TO]
if to_ in INDEX_TYPES:
value_type = INDEX_TYPES[to_]
else:
object_type = get_object_type(to_)
if object_type is None:
matches = difflib.get_close_matches(to_, CORE.id_classes)
raise cv.Invalid(
f"No known mappable class name matches '{to_}'; did you mean one of {', '.join(matches)}?"
)
validator = cv.use_id(object_type)
value_type = IndexType(validator, object_type)
config[CONF_ENTRIES] = {k: value_type.validator(v) for k, v in entries.items()}
if (default_value := config.get(CONF_DEFAULT_VALUE)) is not None:
config[CONF_DEFAULT_VALUE] = value_type.validator(default_value)
unexpected_keys = config.keys() - {
CONF_ENTRIES,
CONF_TO,
CONF_FROM,
CONF_ID,
CONF_DEFAULT_VALUE,
}
if unexpected_keys:
errors = [
cv.Invalid(f"Unexpected key '{k}'", path=[k]) for k in unexpected_keys
]
raise cv.MultipleInvalid(errors)
add_metadata(config[CONF_ID], INDEX_TYPES[config[CONF_FROM]], value_type)
return config
CONFIG_SCHEMA = map_schema
async def to_code(config):
varid = config[CONF_ID]
metadata = get_mapping_metadata(varid.id)
entries = {
metadata.from_.conversion(key): await metadata.to_.convert_value(value)
for key, value in config[CONF_ENTRIES].items()
}
value_type = metadata.to_.data_type
# entries guaranteed to be non-empty here.
value_0 = list(entries.values())[0]
if isinstance(value_0, MockObj) and value_0.op != ".":
value_type = value_type.operator("ptr")
varid.type = mapping_class.template(
metadata.from_.data_type,
value_type,
)
var = MockObj(varid, ".")
decl = VariableDeclarationExpression(varid.type, "", varid, static=True)
add_global(decl)
CORE.register_variable(varid, var)
for key, value in entries.items():
cg.add(var.set(key, value))
if (default_value := config.get(CONF_DEFAULT_VALUE)) is not None:
cg.add(var.set_default_value(await metadata.to_.convert_value(default_value)))
return var