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 range0.0(off) to1.0(fully on), such as a PWM duty cycle.FloatOutputinherits fromBinaryOutput, so a float output can always be used wherever a binary output is expected -1.0and0.0stand in for on and off.
We have two example, minimal components to start from:
empty_binary_output and
empty_float_output.
Python
Section titled “Python”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_outputThe typical imports and class declaration look like this:
import esphome.codegen as cgimport esphome.config_validation as cvfrom esphome.components import outputfrom 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).
Configuration schema
Section titled “Configuration schema”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)Code generation
Section titled “Code generation”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_outputWriting state to hardware
Section titled “Writing state to hardware”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.
Useful members
Section titled “Useful members”is_inverted(): whether the user configuredinverted: true.get_min_power()/get_max_power(): the configured power-scaling bounds for aFloatOutput(0.0/1.0when power scaling is not compiled in).LOG_BINARY_OUTPUT(this)/LOG_FLOAT_OUTPUT(this): convenience macros fordump_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.
Homogeneous channels: a per-channel key
Section titled “Homogeneous channels: a per-channel key”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: 1Each 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 cgfrom esphome.components import outputimport esphome.config_validation as cvfrom esphome.const import CONF_CHANNEL, CONF_IDfrom 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).
Heterogeneous channels: a type key
Section titled “Heterogeneous channels: a type key”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.
Copyright © 2026 ESPHome - A project from the Open Home Foundation