Component-Level Substitutions for Dynamic YAML Anchors
Summary
Add support for component-level (local) substitutions that can be used with YAML anchors to enable parameterized, reusable component configurations without code duplication.
Problem Statement
Currently, ESPHome supports substitutions only at the global file level. When using YAML anchors to create reusable component templates, there is no way to pass dynamic parameters (like IDs or entity names) into nested anchor references. This severely limits the usefulness of YAML anchors for DRY (Don’t Repeat Yourself) configurations.
Current Limitation Example
# This does NOT work - substitutions can't be defined at component level
binary_sensor:
- platform: homeassistant
<<: *Anchor_Button
id: button_1
substitutions: # ❌ Invalid option
btn_last_change_id: button_1_last_change
Use Case
A common pattern is creating multiple similar components (buttons, sensors, switches) that differ only in their IDs and related parameters. For example:
Desired Configuration:
- Multiple binary sensors monitoring Home Assistant entities
- Each sensor needs to track its last state change timestamp
- Each sensor writes to its own unique global variable
- All sensors share identical logic except for ID references
Current Workaround Required: Users must either:
- Duplicate the entire
on_state block for each component
- Use a single shared global variable (losing per-component tracking)
- Create complex lambda code with maps/arrays (less maintainable)
- Use multiple
!include files with global substitution changes (fragile)
Proposed Solution
Option A: Component-Level Substitutions (Preferred)
Allow substitutions: as a valid key within individual component definitions, with scope limited to that component:
substitutions:
# Global substitutions
btn_1_device: "Office Desk Lamp"
# Define reusable anchor with substitution placeholder
.globals_set_template: &Anchor_Button_LastChange
id: ${btn_last_change_id} # Will be resolved from component-level substitution
value: !lambda |-
char time_str[20];
id(my_time).now().strftime(time_str, sizeof(time_str), "%I:%M:%S %p");
return std::string(time_str);
.binary_sensor: &Anchor_Button
internal: true
on_state:
then:
- globals.set:
<<: *Anchor_Button_LastChange
binary_sensor:
- platform: homeassistant
<<: *Anchor_Button
id: button_1
entity_id: switch.s31_green_relay
name: ${btn_1_device}
substitutions: # ✅ Component-level substitutions
btn_last_change_id: button_1_last_change
- platform: homeassistant
<<: *Anchor_Button
id: button_2
entity_id: switch.s31_blue_relay
name: ${btn_2_device}
substitutions: # ✅ Different value for this component
btn_last_change_id: button_2_last_change
Behavior:
- Component-level substitutions override global substitutions within that component’s scope
- Substitutions are resolved after YAML anchors are merged
- Scope is limited to the component and its children (nested anchors, lambdas, etc.)
Option B: Lambda Context Variables (Alternative)
Provide automatic context variables in lambdas that reference the parent component:
.binary_sensor: &Anchor_Button
internal: true
on_state:
then:
- lambda: |-
// Access parent component ID automatically
auto component_id = __component_id__; // New built-in variable
char time_str[20];
id(my_time).now().strftime(time_str, sizeof(time_str), "%I:%M:%S %p");
id(button_last_changes)[component_id] = std::string(time_str);
Built-in context variables could include:
__component_id__ - The string ID of the parent component
__component_name__ - The friendly name of the parent component
__component_type__ - The platform/type (e.g., “homeassistant”)
Benefits
- Eliminates Code Duplication: Write complex logic once, reuse everywhere
- Improves Maintainability: Changes to shared logic update all instances automatically
- Reduces Errors: Single source of truth prevents copy-paste mistakes
- Enhances Readability: Configuration intent is clearer with parameterized templates
- Backward Compatible: Existing configurations without component-level substitutions continue to work unchanged
Technical Considerations
- Substitution resolution order: Global → Component-level → Lambda evaluation
- Circular reference detection needed if component-level substitutions reference other substitutions
- Documentation should clearly explain scope and resolution order
- Consider performance impact of multiple substitution passes during config parsing
Alternative Solutions Considered
- !include with global substitutions: Fragile, requires changing global state between components
- Lambda maps/dictionaries: Works but reduces readability and requires C++ knowledge
- Separate anchors per component: Defeats the purpose of DRY principles
- ESPHome packages: Too heavyweight for simple parameterization within a single file
Implementation Priority
Medium-High - This would significantly improve configuration maintainability for users with multiple similar components, which is a very common use case in home automation.
Example Real-World Impact
A configuration with 10 similar binary sensors currently requires:
- ~200 lines of duplicated YAML for the
on_state logic
- Manual synchronization when logic needs to change
With component-level substitutions:
- ~20 lines for the anchor definition
- ~50 lines for the 10 sensor declarations (5 lines each)
- Single point of maintenance for shared logic
Savings: ~130 lines and significantly reduced maintenance burden