Skip to content

Listener Interface Pattern for OTA and Alarm Control Panel

The OTA component now uses a listener interface instead of std::function callbacks. The alarm control panel component has removed per-state callback methods in favor of a unified state callback.

This is a breaking change for external components in ESPHome 2026.1.0 and later.

Background

PR #12167: OTA listener interface Replaces std::function callbacks with an OTAStateListener interface. Saves 352 bytes flash; listener pointers use 4 bytes vs 16+ bytes for std::function.

PR #12171: Alarm control panel callback consolidation Removes 7 per-state callbacks in favor of a unified state callback. Saves 176 bytes flash on ESP8266.

What's Changing

OTA Component

// Before - std::function callback
ota->add_on_state_callback([](ota::OTAState state, float progress, uint8_t error) {
  // handle state
});

// After - listener interface
class MyComponent : public ota::OTAStateListener {
 public:
  void on_ota_state(ota::OTAState state, float progress, uint8_t error) override {
    // handle state
  }
};

// Registration
ota->add_state_listener(this);

Alarm Control Panel

// Before - per-state callbacks (removed)
alarm->add_on_triggered_callback([]() { /* triggered */ });
alarm->add_on_arming_callback([]() { /* arming */ });
alarm->add_on_pending_callback([]() { /* pending */ });
alarm->add_on_armed_home_callback([]() { /* armed home */ });
alarm->add_on_armed_night_callback([]() { /* armed night */ });
alarm->add_on_armed_away_callback([]() { /* armed away */ });
alarm->add_on_disarmed_callback([]() { /* disarmed */ });

// After - unified state callback (check get_state() inside)
alarm->add_on_state_callback([alarm]() {
  if (alarm->get_state() == alarm_control_panel::ACP_STATE_TRIGGERED) {
    // triggered
  }
});

Who This Affects

  • External components using OTA state callbacks
  • External components using alarm control panel per-state callbacks

Standard YAML configurations are not affected.

Migration Guide

OTA: Listener Interface

1. Implement the listener interface

#include "esphome/components/ota/ota_backend.h"

static const char *const TAG = "my_component";

class MyComponent : public Component, public ota::OTAStateListener {
 public:
  void set_ota_parent(ota::OTAComponent *parent) { this->ota_parent_ = parent; }

  void setup() override {
    // Register with OTA component (parent set via Python codegen)
    if (this->ota_parent_ != nullptr) {
      this->ota_parent_->add_state_listener(this);
    }
  }

  void on_ota_state(ota::OTAState state, float progress, uint8_t error) override {
    switch (state) {
      case ota::OTA_STARTED:
        ESP_LOGI(TAG, "OTA started");
        break;
      case ota::OTA_IN_PROGRESS:
        ESP_LOGI(TAG, "OTA progress: %.1f%%", progress * 100);
        break;
      case ota::OTA_COMPLETED:
        ESP_LOGI(TAG, "OTA completed");
        break;
      case ota::OTA_ERROR:
        ESP_LOGE(TAG, "OTA error: %d", error);
        break;
      default:
        break;
    }
  }

 protected:
  ota::OTAComponent *ota_parent_{nullptr};
};

2. Python code generation

# In __init__.py
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.components import ota

CONF_OTA_ID = "ota_id"

CONFIG_SCHEMA = cv.Schema({
    cv.GenerateID(): cv.declare_id(MyComponent),
    cv.Optional(CONF_OTA_ID): cv.use_id(ota.OTAComponent),
}).extend(cv.COMPONENT_SCHEMA)

async def to_code(config):
    var = cg.new_Pvariable(config[CONF_ID])
    await cg.register_component(var, config)

    # Request OTA listener support
    await ota.request_ota_state_listeners()

    # Link to OTA component if specified
    if CONF_OTA_ID in config:
        ota_component = await cg.get_variable(config[CONF_OTA_ID])
        cg.add(var.set_ota_parent(ota_component))

Alarm Control Panel: Unified Callback

Replace per-state callbacks

// Before - multiple callbacks
alarm->add_on_triggered_callback([]() { handle_triggered(); });
alarm->add_on_arming_callback([]() { handle_arming(); });
alarm->add_on_disarmed_callback([]() { handle_disarmed(); });

// After - single callback that checks get_state()
alarm->add_on_state_callback([alarm]() {
  using namespace alarm_control_panel;
  switch (alarm->get_state()) {
    case ACP_STATE_TRIGGERED:
      handle_triggered();
      break;
    case ACP_STATE_ARMING:
      handle_arming();
      break;
    case ACP_STATE_DISARMED:
      handle_disarmed();
      break;
    default:
      break;
  }
});

Available states

// All states in alarm_control_panel namespace:
alarm_control_panel::ACP_STATE_DISARMED
alarm_control_panel::ACP_STATE_ARMED_HOME
alarm_control_panel::ACP_STATE_ARMED_AWAY
alarm_control_panel::ACP_STATE_ARMED_NIGHT
alarm_control_panel::ACP_STATE_ARMED_VACATION
alarm_control_panel::ACP_STATE_ARMED_CUSTOM_BYPASS
alarm_control_panel::ACP_STATE_PENDING
alarm_control_panel::ACP_STATE_ARMING
alarm_control_panel::ACP_STATE_DISARMING
alarm_control_panel::ACP_STATE_TRIGGERED

Supporting Multiple ESPHome Versions

OTA

#if ESPHOME_VERSION_CODE >= VERSION_CODE(2026, 1, 0)
// New API - listener interface
class MyComponent : public Component, public ota::OTAStateListener {
 public:
  void set_ota_parent(ota::OTAComponent *parent) { this->ota_parent_ = parent; }
  void setup() override {
    if (this->ota_parent_) this->ota_parent_->add_state_listener(this);
  }
  void on_ota_state(ota::OTAState state, float progress, uint8_t error) override {
    // handle state
  }
 protected:
  ota::OTAComponent *ota_parent_{nullptr};
};
#else
// Old API - std::function callback (also requires OTA component pointer)
class MyComponent : public Component {
 public:
  void set_ota_parent(ota::OTAComponent *parent) { this->ota_parent_ = parent; }
  void setup() override {
    if (this->ota_parent_) {
      this->ota_parent_->add_on_state_callback(
        [](ota::OTAState state, float progress, uint8_t error) {
          // handle state
        });
    }
  }
 protected:
  ota::OTAComponent *ota_parent_{nullptr};
};
#endif

Alarm Control Panel

#if ESPHOME_VERSION_CODE >= VERSION_CODE(2026, 1, 0)
// New API - unified callback (check get_state() inside)
alarm->add_on_state_callback([alarm]() {
  if (alarm->get_state() == alarm_control_panel::ACP_STATE_TRIGGERED) {
    // handle triggered
  }
});
#else
// Old API - per-state callback
alarm->add_on_triggered_callback([]() {
  // handle triggered
});
#endif

Timeline

  • ESPHome 2026.1.0 (January 2026): New APIs active, old methods removed
  • No deprecation period - methods removed directly

Finding Code That Needs Updates

# Find OTA callback usage
grep -rn "add_on_state_callback.*OTAState" your_component/
grep -rn "ota.*add_on_state_callback" your_component/

# Find alarm per-state callbacks
grep -rn "add_on_triggered_callback" your_component/
grep -rn "add_on_arming_callback" your_component/
grep -rn "add_on_pending_callback" your_component/
grep -rn "add_on_armed_.*_callback" your_component/
grep -rn "add_on_disarmed_callback" your_component/

Questions?

If you have questions about migrating your external component, please ask in:

Comments

Feel free to leave a comment here to discuss this post wth others. You can ask questions, share your experience, or suggest improvements. If you have a question about a specific feature or issue, please consider using the ESPHome Discord. Stick to English and follow ESPHome's code of conduct. These comments exist on a discussion on GitHub, so you can also comment there directly if you prefer.