Light
The light component is one of ESPHome’s core entity types. A light represents a
device that can be turned on/off and, depending on the hardware, may also support brightness, color temperature, RGB
color or addressable effects.
light works differently from the other entity types. For a sensor or a
switch, your platform class is the entity. For a light, the entity itself is
light::LightState - a class provided entirely by ESPHome core, which handles transitions, flashes, effects and
publishing to the front-end. Your platform instead implements light::LightOutput, a small interface that only knows
how to report what the hardware supports and how to write a given color/brightness to it. LightState owns your
LightOutput and drives it.
We have an example, minimal light component which is a good starting point.
Python
Section titled “Python”A light platform lives in a light.py file (or a light/__init__.py package) inside your component’s directory. This
file name tells ESPHome that the component provides a light platform, allowing the user to configure it under the
light: block:
light: - platform: my_component name: "My Light"The typical imports and class declaration look like this:
import esphome.codegen as cgimport esphome.config_validation as cvfrom esphome.components import light
my_light_ns = cg.esphome_ns.namespace("my_light")MyLightOutput = my_light_ns.class_("MyLightOutput", light.LightOutput)Note that MyLightOutput inherits only from light.LightOutput - not from a component base. LightState (the
entity) is registered as the Component on your behalf; your output only needs cg.Component in addition if it has
its own initialization to perform (see Optional Component behaviour below).
Configuration schema
Section titled “Configuration schema”Use the light.light_schema() helper, passing your output class and the kind of light you’re implementing via
type_=:
from esphome.components.light import LightType
CONFIG_SCHEMA = light.light_schema(MyLightOutput, type_=LightType.RGB)LightType is an enum with four members - BINARY, BRIGHTNESS_ONLY, RGB and ADDRESSABLE - and picks which of
the underlying schemas (BINARY_LIGHT_SCHEMA, BRIGHTNESS_ONLY_LIGHT_SCHEMA, RGB_LIGHT_SCHEMA or
ADDRESSABLE_LIGHT_SCHEMA) your platform extends, which in turn determines which options are available (for example
gamma_correct and default_transition_length only apply from BRIGHTNESS_ONLY upwards, and color_correct /
power_supply only apply to ADDRESSABLE) and which class of effects (BINARY_EFFECTS, MONOCHROMATIC_EFFECTS,
RGB_EFFECTS, ADDRESSABLE_EFFECTS) are valid under effects:.
Unlike sensor.sensor_schema() or switch.switch_schema(), the generated ID is declared under CONF_OUTPUT_ID, not
CONF_ID - the id: the user sets on a light refers to the LightState, whose ID is already declared by the base
schema. Your own output object gets its own, separate generated ID:
CONFIG_SCHEMA = light.light_schema(MyLightOutput, type_=LightType.RGB).extend( { cv.Required(CONF_RED): cv.use_id(output.FloatOutput), cv.Required(CONF_GREEN): cv.use_id(output.FloatOutput), cv.Required(CONF_BLUE): cv.use_id(output.FloatOutput), })If you don’t need light_schema()’s convenience defaults (entity_category, icon, default_restore_mode), you can
instead extend the underlying schema constant directly and declare the output ID yourself, as several in-tree
components do:
CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( { cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MyLightOutput), ... })Code generation
Section titled “Code generation”In to_code, use the light.new_light() helper. It creates your output Pvariable, then internally creates and
registers the LightState that wraps it:
async def to_code(config): var = await light.new_light(config) await cg.add_variable(var.set_red, config[CONF_RED]) ...If your output class also inherits cg.Component (see below), construct it yourself with cg.new_Pvariable(),
register it as a component, and call await light.register_light(var, config) instead of new_light():
async def to_code(config): var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await cg.register_component(var, config) await light.register_light(var, config)The C++ class implements light::LightOutput:
#include "esphome/components/light/light_output.h"#include "esphome/components/output/float_output.h"
namespace esphome::my_light {
class MyLightOutput : public light::LightOutput { public: void set_red(output::FloatOutput *red) { this->red_ = red; } void set_green(output::FloatOutput *green) { this->green_ = green; } void set_blue(output::FloatOutput *blue) { this->blue_ = blue; }
light::LightTraits get_traits() override { auto traits = light::LightTraits(); traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; }
void write_state(light::LightState *state) override { float red, green, blue; state->current_values_as_rgb(&red, &green, &blue); this->red_->set_level(red); this->green_->set_level(green); this->blue_->set_level(blue); }
protected: output::FloatOutput *red_; output::FloatOutput *green_; output::FloatOutput *blue_;};
} // namespace esphome::my_lightDeclaring supported color modes
Section titled “Declaring supported color modes”get_traits() tells LightState what your hardware can do. Call set_supported_color_modes() with one or more
ColorMode values:
ON_OFF: on/off only, no dimming.BRIGHTNESS: dimmable, single channel.WHITE: a separate white channel (used together with another color mode, e.g.RGB_WHITE).COLOR_TEMPERATURE: dimmable white with adjustable color temperature (also setset_min_mireds()/set_max_mireds()).COLD_WARM_WHITE: independently dimmable cold/warm white channels.RGB: red/green/blue with a shared brightness.RGB_WHITE,RGB_COLOR_TEMPERATURE,RGB_COLD_WARM_WHITE: RGB combined with a white channel.
Each ColorMode is really a bitmask of ColorCapability flags (ON_OFF, BRIGHTNESS, WHITE,
COLOR_TEMPERATURE, COLD_WARM_WHITE, RGB); if a light supports switching between several distinct modes (for
example plain RGB and a separate color-temperature white channel), pass more than one ColorMode to
set_supported_color_modes().
Writing state to hardware
Section titled “Writing state to hardware”The one method you must implement is write_state(LightState *state). It is called from loop() by the light core
every time the current (post-transition) values have changed, and you should push them to hardware immediately.
Unlike a switch or fan, you never call publish_state() yourself - LightState owns the state and publishing.
Read the values you need through state->current_values_as_*(), not the raw state->current_values fields directly:
these accessors apply gamma correction and convert the internal, mode-specific representation into the shape your
hardware expects:
current_values_as_binary(bool *binary)current_values_as_brightness(float *brightness)current_values_as_rgb(float *red, float *green, float *blue)current_values_as_rgbw(float *red, float *green, float *blue, float *white)current_values_as_rgbww(float *red, float *green, float *blue, float *cold_white, float *warm_white)current_values_as_rgbct(float *red, float *green, float *blue, float *color_temperature, float *white_brightness)current_values_as_cwww(float *cold_white, float *warm_white)current_values_as_ct(float *color_temperature, float *white_brightness)
Pick the accessor matching the ColorMode(s) you declared, regardless of what the user’s automations set - a light in
COLOR_TEMPERATURE mode should call current_values_as_ct(), and so on.
Optional Component behaviour
Section titled “Optional Component behaviour”If your output needs its own hardware initialization (for example a status LED on a raw GPIO pin), you can also
inherit Component and implement setup()/dump_config() as usual - just remember to cg.register_component() it
in Python in addition to light.register_light(), as shown above. If your output has no independent setup to do (most
simple color outputs don’t - the wrapped output::FloatOutput/output::BinaryOutput instances handle their own
setup), skip Component entirely, as in the example above.
Two further optional overrides exist for advanced cases:
setup_state(LightState *state): called once when theLightStateis set up; use it if you need a pointer back to the state (for example to register aLightRemoteValuesListener).update_state(LightState *state): called every time the current values change, beforewrite_state(). Every call towrite_state()is preceded by at least one call toupdate_state(), butupdate_state()can also fire on its own without a followingwrite_state()- useful for cheap bookkeeping that should track every change, separate from the (potentially more expensive) actual hardware write.
Addressable LED strips
Section titled “Addressable LED strips”A strip driver derives from light::AddressableLight and keeps its channel order in a light::ChannelColors from
esphome/components/light/channel_colors.h, set from the rgb_order option. Two helpers on it cover the parts every
strip driver used to write out by hand:
light::LightTraits get_traits() override { return this->channel_colors_.traits(); }
light::ESPColorView get_view_internal(int32_t index) const override { return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_);}traits()returns aLightTraitsthat supportsColorMode::RGB, orColorMode::RGB_WHITEandColorMode::WHITEwhen the order has a white channel.view()returns the view of LEDindexin a buffer ofbytes_per_led()bytes per LED. When the buffer or the effect data is null, for example after an allocation failed insetup(), it returns a shared scratch pixel with no effect data instead.LightStatekeeps writing pixels after a failed setup, so this avoids a write through a null pointer.
A driver with a maximum refresh rate calls throttle_show_(), a protected helper on AddressableLight, at the start of
write_state(). It returns true, with the frame queued for the next loop, until the interval has passed. An interval
of 0 never holds a frame back, and the check stays correct when micros() wraps:
// max_refresh_rate_ is a uint32_t in µs, 0 for no limitvoid MyStripLightOutput::write_state(light::LightState *state) { uint32_t now = micros(); if (this->throttle_show_(now, this->last_refresh_, this->max_refresh_rate_)) return; this->last_refresh_ = now; ...}These helpers are available in ESPHome 2026.11.0 and later (esphome/esphome#21445).
Useful members
Section titled “Useful members”ColorMode/ColorCapability: the color-mode and capability enums described above.LightTraits::set_min_mireds()/set_max_mireds(): the color-temperature range, required when supportingCOLOR_TEMPERATURE.create_default_transition(): override to provide a customLightTransformerfor transitions; the default uses a smooth (gamma-aware) transition.state->get_effect_name(): the name of the currently active effect, or"None".
Mirroring a light that lives elsewhere
Section titled “Mirroring a light that lives elsewhere”Some outputs do not drive hardware but stand in for a light that is controlled somewhere else, for example the
homeassistant light platform, which follows a Home Assistant light entity and sends every local change back as an
action. Such an output learns the light’s state from outside, and that state must not go through a LightCall: a
call validates against the traits, may start a transition, and ends in a write_state() the output would then send
back to where the state came from.
LightState::set_external_state() takes the reported values over directly:
LightColorValues values = this->state_->remote_values; // attributes not reported keep what the light hasvalues.set_state(true);values.set_brightness(reported_brightness / 255.0f);if (!this->state_->set_external_state(values)) { // A local change still waits to be written, or a transition or flash runs: that change is // newer than the report, and a fresh report follows once it has reached the other side return;}The values become the light’s current and target values at once and are published to the API and the other
listeners, including the on_turn_on and on_turn_off triggers; nothing is written to the output and nothing is
saved. Nothing is published when the values did not change, unless an effect was running: that is published so
clients drop the effect name.
The call returns false and changes nothing while a local change still waits for the next write_state() or while a
transition or flash runs. The output decides what to do then: drop the report when the other side will report again
after the local change reaches it, or try again from its next write_state(). A running effect does not end by itself,
so the call stops it and takes the report over.
Since reported state never reaches write_state(), every write the output sees is a local change, except the very
first one, which carries the initial state the light core sets up at boot.
Exposing multiple lights from one component
Section titled “Exposing multiple lights from one component”Hardware sometimes drives more than one light from a single controller - an LED driver hub might expose a plain
dimmable white channel on one header and a full RGB channel on another. Since the entity is always the core-owned
light::LightState, exposing a light means registering a LightOutput and letting light.new_light() wrap it in
its own LightState. There are still two established ways to let the user configure which outputs exist, and both
are valid - which one fits depends on how the hardware itself is shaped.
A hub plus a type on the platform
Section titled “A hub plus a type on the platform”If your component has a top-level hub, let the user add one light: entry per channel, distinguished by a type
key, using cv.typed_schema() with key=CONF_TYPE. Each type maps to that channel’s real
light.light_schema(class_, type_=LightType.X) call:
my_light_hub: id: the_hublight: - platform: my_light_hub type: white name: "Hallway Light" - platform: my_light_hub type: rgb name: "Accent Light"_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_MY_HUB_ID): cv.use_id(MyHub)})CONFIG_SCHEMA = cv.typed_schema( { "white": light.light_schema(MyHubWhiteLightOutput, type_=LightType.BRIGHTNESS_ONLY).extend(_HUB_ID_SCHEMA), "rgb": light.light_schema(MyHubRgbLightOutput, type_=LightType.RGB).extend(_HUB_ID_SCHEMA), }, key=CONF_TYPE,)
async def to_code(config): var = await light.new_light(config) await cg.register_parented(var, config[CONF_MY_HUB_ID])The generated ID for each type lives under CONF_OUTPUT_ID, not CONF_ID, and new_light() already registers the
LightState as a component, so to_code needs no separate cg.register_component() call. Each type gets its own
schema, so the white channel and the RGB channel can declare both a different output class and a different
LightType. See Exposing multiple sensors from one component
for a fully worked example.
Sub-configs on a single platform entry
Section titled “Sub-configs on a single platform entry”Some components instead nest their channels as optional sub-keys under one platform entry, the way
dht nests temperature: and humidity:
under one sensor: entry. This is a heavier lift for light: unlike sensor.sensor_schema(),
light.light_schema() has no default for class_ or type_, so every sub-config needs its own output class and
explicit LightType, and to_code calls light.new_light() per configured channel:
if channel_0_config := config.get(CONF_CHANNEL_0): output = await light.new_light(channel_0_config) cg.add(var.set_channel_0_light_output(output))There is no SUB_LIGHT macro (only binary_sensor, button, number, select, sensor, switch, text_sensor
have one), so declare the pointer member and setter by hand, and null-check before use:
class MyHubComponent : public Component { public: void set_channel_0_light_output(light::LightOutput *output) { this->channel_0_light_output_ = output; } protected: light::LightOutput *channel_0_light_output_{nullptr};};Choosing between them
Section titled “Choosing between them”Neither pattern is deprecated; they express different relationships between a driver and the outputs it exposes, so let the hardware decide:
- Sub-configs fit a small, fixed set of channels from a single LED driver board - one physical device maps to one YAML block, which is easier to read.
- A hub plus
typefits when a hub component already exists to describe the connection, and the set of channels is large or open-ended, or different channels need different color modes (LightType) or output classes.
If both descriptions fit equally well, follow whichever pattern the surrounding component already uses. In a new component with no hub, sub-configs are usually the smaller change.
For everything else, the component implements the usual set of methods as described here.
Copyright © 2026 ESPHome - A project from the Open Home Foundation