diff --git a/CODEOWNERS b/CODEOWNERS index 433820d624..d080563028 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -250,6 +250,7 @@ esphome/components/ltr501/* @latonita esphome/components/ltr_als_ps/* @latonita esphome/components/lvgl/* @clydebarrow esphome/components/m5stack_8angle/* @rnauber +esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell esphome/components/max31865/* @DAVe3283 diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index c3381cfd70..c39c5924af 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -3,6 +3,8 @@ from esphome.const import CONF_BLUE, CONF_GREEN, CONF_ID, CONF_RED, CONF_WHITE ColorStruct = cg.esphome_ns.struct("Color") +INSTANCE_TYPE = ColorStruct + MULTI_CONF = True CONF_RED_INT = "red_int" diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 20b041a321..fbf61c105c 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -291,6 +291,8 @@ SOURCE_WEB = "web" Image_ = image_ns.class_("Image") +INSTANCE_TYPE = Image_ + def compute_local_image_path(value) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py new file mode 100644 index 0000000000..79657084fa --- /dev/null +++ b/esphome/components/mapping/__init__.py @@ -0,0 +1,134 @@ +import difflib + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_FROM, CONF_ID, CONF_TO +from esphome.core import CORE +from esphome.cpp_generator import MockObj, VariableDeclarationExpression, add_global +from esphome.loader import get_component + +CODEOWNERS = ["@clydebarrow"] +MULTI_CONF = True + +map_ = cg.std_ns.class_("map") + +CONF_ENTRIES = "entries" +CONF_CLASS = "class" + + +class IndexType: + """ + Represents a type of index in a map. + """ + + def __init__(self, validator, data_type, conversion): + self.validator = validator + self.data_type = data_type + self.conversion = conversion + + +INDEX_TYPES = { + "int": IndexType(cv.int_, cg.int_, int), + "string": IndexType(cv.string, cg.std_string, str), +} + + +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(map_), + 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_): + """ + 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 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 list is required for a map") + entries = config[CONF_ENTRIES] + if len(entries) == 0: + raise cv.Invalid("Map must have at least one entry") + to_ = config[CONF_TO] + if to_ in INDEX_TYPES: + value_type = INDEX_TYPES[to_].validator + else: + value_type = get_object_type(to_) + if value_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)}?" + ) + value_type = cv.use_id(value_type) + config[CONF_ENTRIES] = {k: value_type(v) for k, v in entries.items()} + return config + + +CONFIG_SCHEMA = map_schema + + +async def to_code(config): + entries = config[CONF_ENTRIES] + from_ = config[CONF_FROM] + to_ = config[CONF_TO] + index_conversion = INDEX_TYPES[from_].conversion + index_type = INDEX_TYPES[from_].data_type + if to_ in INDEX_TYPES: + value_conversion = INDEX_TYPES[to_].conversion + value_type = INDEX_TYPES[to_].data_type + entries = { + index_conversion(key): value_conversion(value) + for key, value in entries.items() + } + else: + entries = { + index_conversion(key): await cg.get_variable(value) + for key, value in entries.items() + } + value_type = get_object_type(to_) + if list(entries.values())[0].op != ".": + value_type = value_type.operator("ptr") + varid = config[CONF_ID] + varid.type = map_.template(index_type, value_type) + var = MockObj(varid, ".") + decl = VariableDeclarationExpression(varid.type, "", varid) + add_global(decl) + CORE.register_variable(varid, var) + + for key, value in entries.items(): + cg.add(var.insert((key, value))) + return var diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1a81a6d6cd..3a02c95c82 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -518,6 +518,8 @@ class EsphomeCore: self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False + # A list of all known ID classes + self.id_classes = {} def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index eb0bd25d1d..93ebb4cb95 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -789,13 +789,17 @@ class MockObj(Expression): def class_(self, name: str, *parents: "MockObjClass") -> "MockObjClass": op = "" if self.op == "" else "::" - return MockObjClass(f"{self.base}{op}{name}", ".", parents=parents) + result = MockObjClass(f"{self.base}{op}{name}", ".", parents=parents) + CORE.id_classes[str(result)] = result + return result def struct(self, name: str) -> "MockObjClass": return self.class_(name) def enum(self, name: str, is_class: bool = False) -> "MockObj": - return MockObjEnum(enum=name, is_class=is_class, base=self.base, op=self.op) + result = MockObjEnum(enum=name, is_class=is_class, base=self.base, op=self.op) + CORE.id_classes[str(result)] = result + return result def operator(self, name: str) -> "MockObj": """Various other operations. diff --git a/esphome/loader.py b/esphome/loader.py index 0fb4187b04..dbaa2ac661 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -91,6 +91,10 @@ class ComponentManifest: def codeowners(self) -> list[str]: return getattr(self.module, "CODEOWNERS", []) + @property + def instance_type(self) -> list[str]: + return getattr(self.module, "INSTANCE_TYPE", None) + @property def final_validate_schema(self) -> Optional[Callable[[ConfigType], None]]: """Components can declare a `FINAL_VALIDATE_SCHEMA` cv.Schema that gets called diff --git a/tests/components/mapping/common.yaml b/tests/components/mapping/common.yaml new file mode 100644 index 0000000000..07ca458146 --- /dev/null +++ b/tests/components/mapping/common.yaml @@ -0,0 +1,71 @@ +image: + grayscale: + alpha_channel: + - file: ../../pnglogo.png + id: image_1 + resize: 50x50 + - file: ../../pnglogo.png + id: image_2 + resize: 50x50 + +mapping: + - id: weather_map + from: string + to: "image::Image" + entries: + clear-night: image_1 + sunny: image_2 + - id: weather_map_1 + from: string + to: esphome::image::Image + entries: + clear-night: image_1 + sunny: image_2 + - id: weather_map_2 + from: string + to: image + entries: + clear-night: image_1 + sunny: image_2 + - id: int_map + from: int + to: string + entries: + 1: "one" + 2: "two" + 3: "three" + 77: "seventy-seven" + - id: string_map + from: string + to: int + entries: + one: 1 + two: 2 + three: 3 + seventy-seven: 77 + - id: color_map + from: string + to: color + entries: + red: red_id + blue: blue_id + green: green_id + +color: + - id: red_id + red: 1.0 + green: 0.0 + blue: 0.0 + - id: green_id + red: 0.0 + green: 1.0 + blue: 0.0 + - id: blue_id + red: 0.0 + green: 0.0 + blue: 1.0 + +display: + lambda: |- + it.image(0, 0, id(weather_map)[0]); + it.image(0, 100, id(weather_map)[1]); diff --git a/tests/components/mapping/test.esp32-ard.yaml b/tests/components/mapping/test.esp32-ard.yaml new file mode 100644 index 0000000000..951a6061f6 --- /dev/null +++ b/tests/components/mapping/test.esp32-ard.yaml @@ -0,0 +1,17 @@ +spi: + - id: spi_main_lcd + clk_pin: 16 + mosi_pin: 17 + miso_pin: 15 + +display: + - platform: ili9xxx + id: main_lcd + model: ili9342 + cs_pin: 12 + dc_pin: 13 + reset_pin: 21 + invert_colors: false + +packages: + map: !include common.yaml diff --git a/tests/components/mapping/test.esp32-c3-ard.yaml b/tests/components/mapping/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..55e5719e50 --- /dev/null +++ b/tests/components/mapping/test.esp32-c3-ard.yaml @@ -0,0 +1,17 @@ +spi: + - id: spi_main_lcd + clk_pin: 6 + mosi_pin: 7 + miso_pin: 5 + +display: + - platform: ili9xxx + id: main_lcd + model: ili9342 + cs_pin: 8 + dc_pin: 9 + reset_pin: 10 + invert_colors: false + +packages: + map: !include common.yaml diff --git a/tests/components/mapping/test.esp32-c3-idf.yaml b/tests/components/mapping/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..55e5719e50 --- /dev/null +++ b/tests/components/mapping/test.esp32-c3-idf.yaml @@ -0,0 +1,17 @@ +spi: + - id: spi_main_lcd + clk_pin: 6 + mosi_pin: 7 + miso_pin: 5 + +display: + - platform: ili9xxx + id: main_lcd + model: ili9342 + cs_pin: 8 + dc_pin: 9 + reset_pin: 10 + invert_colors: false + +packages: + map: !include common.yaml diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml new file mode 100644 index 0000000000..951a6061f6 --- /dev/null +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -0,0 +1,17 @@ +spi: + - id: spi_main_lcd + clk_pin: 16 + mosi_pin: 17 + miso_pin: 15 + +display: + - platform: ili9xxx + id: main_lcd + model: ili9342 + cs_pin: 12 + dc_pin: 13 + reset_pin: 21 + invert_colors: false + +packages: + map: !include common.yaml diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dd4642b8fe --- /dev/null +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -0,0 +1,17 @@ +spi: + - id: spi_main_lcd + clk_pin: 14 + mosi_pin: 13 + miso_pin: 12 + +display: + - platform: ili9xxx + id: main_lcd + model: ili9342 + cs_pin: 5 + dc_pin: 15 + reset_pin: 16 + invert_colors: false + +packages: + map: !include common.yaml diff --git a/tests/components/mapping/test.host.yaml b/tests/components/mapping/test.host.yaml new file mode 100644 index 0000000000..98406767a4 --- /dev/null +++ b/tests/components/mapping/test.host.yaml @@ -0,0 +1,12 @@ +display: + - platform: sdl + id: sdl_display + update_interval: 1s + auto_clear_enabled: false + show_test_card: true + dimensions: + width: 450 + height: 600 + +packages: + map: !include common.yaml diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml new file mode 100644 index 0000000000..1b7e796246 --- /dev/null +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -0,0 +1,17 @@ +spi: + - id: spi_main_lcd + clk_pin: 2 + mosi_pin: 3 + miso_pin: 4 + +display: + - platform: ili9xxx + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 21 + reset_pin: 22 + invert_colors: false + +packages: + map: !include common.yaml