Skip to content

Output

The output component is a hardware abstraction base, not an entity. It represents something that can be driven to a state - a GPIO pin, a PWM channel, a DAC - without being shown to the user directly. Outputs are consumed by other components: a light or fan platform might own one to drive its hardware, and the output.turn_on / output.turn_off / output.set_level actions let users control a standalone output straight from YAML.

There are two flavors:

  • output::BinaryOutput: a simple on/off output.
  • output::FloatOutput: a variable-level output in the range 0.0 (off) to 1.0 (fully on), such as a PWM duty cycle. FloatOutput inherits from BinaryOutput, so a float output can always be used wherever a binary output is expected - 1.0 and 0.0 stand in for on and off.

We have two example, minimal components to start from: empty_binary_output and empty_float_output.

An output platform lives in an output.py file (or output/__init__.py package) inside your component’s directory, allowing the user to configure it under the output: block:

output:
- platform: my_component
id: my_output

The typical imports and class declaration look like this:

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import output
from esphome.const import CONF_ID
my_output_ns = cg.esphome_ns.namespace("my_output")
MyOutput = my_output_ns.class_("MyOutput", output.FloatOutput, cg.Component)

Note that MyOutput inherits from both output.FloatOutput (or output.BinaryOutput) and a component base (cg.Component here).

Unlike sensor or switch, the output module has no output_schema() / new_output() pair. Instead you extend the base schema directly - output.BINARY_OUTPUT_SCHEMA or output.FLOAT_OUTPUT_SCHEMA - which supplies the options common to every output (inverted, power_supply, and for float outputs min_power / max_power / zero_means_zero). Because there is no helper to declare the ID for you, you add CONF_ID to the schema yourself:

CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(MyOutput),
}
).extend(cv.COMPONENT_SCHEMA)

There is no new_output() helper either. In to_code, create the variable yourself with cg.new_Pvariable(), then call output.register_output() to apply the common options (inverted, power_supply, min_power/max_power for float outputs) from the configuration:

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

The C++ class inherits from output::BinaryOutput or output::FloatOutput alongside its component base:

#include "esphome/core/component.h"
#include "esphome/components/output/float_output.h"
namespace esphome::my_output {
class MyOutput : public output::FloatOutput, public Component {
public:
void dump_config() override;
protected:
void write_state(float state) override;
};
} // namespace esphome::my_output

The one method you must implement is the protected virtual write_state(). There is no control()/Call object here - callers (other components, or the output.turn_on / output.set_level actions) call the public turn_on(), turn_off(), set_state(bool) or set_level(float) methods on the base class, which apply inversion (and, for FloatOutput, power-supply requesting plus min/max power scaling when USE_OUTPUT_FLOAT_POWER_SCALING is enabled) before calling your write_state() with the already-adjusted value:

void MyOutput::write_state(float state) {
// `state` has already had inversion and power scaling applied - just drive the hardware.
this->write_pwm_duty_(state);
}

For a binary output the override is void write_state(bool state) override; instead, and it likewise receives the already-inverted value.

There is no publish_state() to call back: an output has no front-end state to report. The component that owns the output (a light, fan, or the output.set_level action) is responsible for tracking and exposing whatever state it cares about.

  • is_inverted(): whether the user configured inverted: true.
  • get_min_power() / get_max_power(): the configured power-scaling bounds for a FloatOutput (0.0/1.0 when power scaling is not compiled in).
  • LOG_BINARY_OUTPUT(this) / LOG_FLOAT_OUTPUT(this): convenience macros for dump_config() that log the inversion (and, for float outputs, min/max power) state in the standard format.

Exposing multiple outputs from one component

Section titled “Exposing multiple outputs from one component”

Outputs are multiple by nature: PWM drivers, I/O expanders and shift registers all drive many channels from one chip. So unlike the entity types, outputs are rarely modelled as sub-configs nested under a single entry. Instead the hub owns the chip and each channel is its own output: platform entry, which is what lets the user point a separate light, fan or output.set_level action at each one.

When every channel behaves identically and only its index differs, give the platform a required channel key and a use_id reference back to the hub. pca9685 is the canonical example:

pca9685:
frequency: 500Hz
output:
- platform: pca9685
id: red_channel
channel: 0
- platform: pca9685
id: green_channel
channel: 1

Each channel only forwards its level to the hub, so it needs no C++ class of its own. Declare it as output.ApplyFloatOutput (or output.ApplyBinaryOutput), pass the channel as the constructor argument and let code generation write the call to the hub:

import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_ID
from esphome.types import ConfigType
from . import PCA9685Output
DEPENDENCIES = ["pca9685"]
CONF_PCA9685_ID = "pca9685_id"
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(output.ApplyFloatOutput),
cv.GenerateID(CONF_PCA9685_ID): cv.use_id(PCA9685Output),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=15),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_PCA9685_ID])
await output.sub_float_outputs(config, parent=hub).create(
config, None, config[CONF_CHANNEL], apply="write_channel(index, state)"
)
cg.add(hub.register_channel(config[CONF_CHANNEL]))

The hub does the scaling in a public method that takes the channel and the level, which inversion, clamping and power scaling have already adjusted:

void PCA9685Output::write_channel(uint8_t channel, float state) {
auto value = static_cast<uint16_t>(roundf(state * 4096));
if (this->pwm_amounts_[channel] != value)
this->update_ = true;
this->pwm_amounts_[channel] = value;
}

The channel is stored in the output itself (it fits in padding, so it costs no RAM unless a power supply is configured), so every channel of the hub runs the same generated function. register_channel() tells the hub which channels are in use, so it knows which ones to write when it flushes to hardware. Keep a C++ output class only when a channel has state or logic of its own beyond that one call. See Entities that only call the hub for the same pattern on the entity types.

Two outputs on the same channel of one hub would overwrite each other’s value. Reject them with output.final_validate_unique_channel(), passing the platform name and the key that holds the hub’s ID:

FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
platform="pca9685", hub_key=CONF_PCA9685_ID
)

Every output hub with a channel key should use it. It is available in ESPHome 2026.11.0 and later (esphome/esphome#21467).

If the channels are not interchangeable - some binary, some float, or backed by different registers - use cv.typed_schema() with key=CONF_TYPE so each variant gets its own schema, its own C++ class and the correct base schema. modbus_controller’s output platform does exactly this, distinguishing a coil (binary) from a holding register (float):

CONFIG_SCHEMA = cv.typed_schema(
{
"coil": output.BINARY_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(ModbusBinaryOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
}
),
"holding": output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
}
),
},
key=CONF_TYPE,
)

NOTE

Pick the per-channel key when the channels are interchangeable and only an index distinguishes them, and type when they need genuinely different schemas or C++ classes. Either way, keep one output: entry per channel rather than nesting them as sub-configs - consumers reference outputs by ID, so each one has to be independently addressable.

For a comparison with how the entity types handle this, see Exposing multiple sensors from one component.

For everything else, the component implements the usual set of methods as described here.