mirror of
https://github.com/esphome/esphome.git
synced 2026-10-19 04:32:50 +00:00
[homeassistant] Add the light platform (#11354)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
pre-commit-ci-lite[bot]
J. Nick Koston
J. Nick Koston
parent
e54d66e620
commit
dd456ea0b2
@@ -279,6 +279,7 @@ esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/button/* @jesserockz
|
||||
esphome/components/homeassistant/light/* @egormanga
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/select/* @jesserockz
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
|
||||
@@ -208,6 +208,7 @@ WIZARD_SCHEMA = cv.All(
|
||||
WIZARD_INPUT_DOMAINS = (
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"light",
|
||||
"number",
|
||||
"select",
|
||||
"sensor",
|
||||
@@ -216,7 +217,14 @@ WIZARD_INPUT_DOMAINS = (
|
||||
"text_sensor",
|
||||
)
|
||||
# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within
|
||||
WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text")
|
||||
WIZARD_DOMAIN_LIMITED_PLATFORMS = (
|
||||
"button",
|
||||
"light",
|
||||
"number",
|
||||
"select",
|
||||
"switch",
|
||||
"text",
|
||||
)
|
||||
|
||||
|
||||
def wizard_input_ids(api_config: ConfigType) -> set[str]:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "homeassistant.h"
|
||||
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
PROGMEM_STRING_TABLE(UnsetValues, "None", "unavailable", "unknown");
|
||||
|
||||
bool is_unset(StringRef value) { return progmem_string_table_find<UnsetValues>(value.c_str(), value.size()) >= 0; }
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
/// True for what Home Assistant reports when there is no value: "None" for an attribute that is not
|
||||
/// set, "unavailable" and "unknown" for the state of an entity that is not ready
|
||||
bool is_unset(StringRef value);
|
||||
|
||||
/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early.
|
||||
template<typename C> bool emit_utf8(uint32_t code_point, C &on_char) {
|
||||
if (code_point == 0 || code_point > 0x10FFFF)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DEFAULT_TRANSITION_LENGTH,
|
||||
CONF_EFFECTS,
|
||||
CONF_FLASH_TRANSITION_LENGTH,
|
||||
CONF_GAMMA_CORRECT,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_RESTORE_STATE,
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
|
||||
final_validate_entity_id,
|
||||
homeassistant_ns,
|
||||
setup_home_assistant_entity,
|
||||
validate_entity_domain,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@egormanga"]
|
||||
DEPENDENCIES = ["api"]
|
||||
AUTO_LOAD = ["homeassistant"]
|
||||
|
||||
CONF_REPORT_TIMEOUT = "report_timeout"
|
||||
SUPPORTED_DOMAINS = ["light"]
|
||||
|
||||
HomeassistantLight = homeassistant_ns.class_(
|
||||
"HomeassistantLight", light.LightOutput, cg.Component
|
||||
)
|
||||
|
||||
_RENDERED_BY_HOME_ASSISTANT = cv.invalid(
|
||||
"Home Assistant renders this light and keeps its state; this option has no effect here"
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
light.light_schema(HomeassistantLight, light.LightType.RGB)
|
||||
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
# A transition would be stepped over the API; the target is sent once instead
|
||||
cv.Optional(
|
||||
CONF_DEFAULT_TRANSITION_LENGTH, default="0s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
# Stored in a byte of seconds; a light polled by Home Assistant can take a minute
|
||||
cv.Optional(CONF_REPORT_TIMEOUT, default="10s"): cv.All(
|
||||
cv.positive_time_period_seconds,
|
||||
cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=255)),
|
||||
),
|
||||
**{
|
||||
cv.Optional(key): _RENDERED_BY_HOME_ASSISTANT
|
||||
for key in (
|
||||
CONF_GAMMA_CORRECT,
|
||||
CONF_EFFECTS,
|
||||
CONF_FLASH_TRANSITION_LENGTH,
|
||||
light.CONF_TRANSITION_STATE_PUBLISH_INTERVAL,
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_RESTORE_STATE,
|
||||
CONF_INITIAL_STATE,
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
validate_entity_domain("light", SUPPORTED_DOMAINS),
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate_entity_id
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
var = await light.new_light(config)
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_report_timeout(config[CONF_REPORT_TIMEOUT].total_seconds))
|
||||
# 1 state + supported_color_modes, min/max_color_temp_kelvin, color_mode, brightness,
|
||||
# color_temp_kelvin and rgb_color in HomeassistantLight::setup()
|
||||
setup_home_assistant_entity(var, config, state_subscription_count=8)
|
||||
@@ -0,0 +1,477 @@
|
||||
#include "homeassistant_light.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "../homeassistant.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "homeassistant.light");
|
||||
|
||||
static constexpr uint32_t ECHO_TIMEOUT_ID = 0;
|
||||
|
||||
using namespace esphome::light;
|
||||
|
||||
namespace {
|
||||
|
||||
// Home Assistant's color mode names, in flash on the ESP8266. The names from "hs" on are every
|
||||
// color mode, all handled as RGB: Home Assistant reports rgb_color for all of them and derives
|
||||
// the white channels from rgb_color itself.
|
||||
PROGMEM_STRING_TABLE(ColorModeNames, "onoff", "brightness", "white", "color_temp", "hs", "xy", "rgb", "rgbw", "rgbww");
|
||||
const uint8_t COLOR_MODES[] PROGMEM = {
|
||||
static_cast<uint8_t>(ColorMode::ON_OFF),
|
||||
static_cast<uint8_t>(ColorMode::BRIGHTNESS),
|
||||
static_cast<uint8_t>(ColorMode::WHITE),
|
||||
static_cast<uint8_t>(ColorMode::COLOR_TEMPERATURE),
|
||||
};
|
||||
|
||||
optional<ColorMode> parse_color_mode(StringRef name) {
|
||||
const int16_t i = progmem_string_table_find<ColorModeNames>(name.c_str(), name.size());
|
||||
if (i < 0) {
|
||||
return nullopt;
|
||||
}
|
||||
return i < sizeof(COLOR_MODES) ? static_cast<ColorMode>(progmem_read_byte(&COLOR_MODES[i])) : ColorMode::RGB;
|
||||
}
|
||||
|
||||
/// Reads a Python tuple of three integers such as "(255, 0, 0)"; false for anything else
|
||||
bool parse_rgb(const char *text, std::array<uint8_t, 3> &values) {
|
||||
for (auto &value : values) {
|
||||
while (*text == ' ' || *text == ',' || *text == '(') {
|
||||
text++;
|
||||
}
|
||||
if (*text < '0' || *text > '9') {
|
||||
return false;
|
||||
}
|
||||
uint16_t number = 0;
|
||||
while (*text >= '0' && *text <= '9') {
|
||||
number = number * 10 + (*text++ - '0');
|
||||
if (number > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
value = static_cast<uint8_t>(number);
|
||||
}
|
||||
while (*text == ' ' || *text == ',' || *text == ')') {
|
||||
text++;
|
||||
}
|
||||
return *text == '\0';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void HomeassistantLight::setup() {
|
||||
auto *server = api::global_api_server;
|
||||
// Home Assistant sends these on subscribe and when they change, so a light that comes up late gets them
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "supported_color_modes", [this](StringRef value) {
|
||||
if (is_unset(value)) {
|
||||
return; // the light is unavailable; its traits stay
|
||||
}
|
||||
// A Python list of the mode names, as strings or as the enum members
|
||||
ColorModeMask modes;
|
||||
char name[ColorModeNames::MAX_LENGTH + 1];
|
||||
size_t length = 0;
|
||||
const bool valid = parse_python_list(
|
||||
value,
|
||||
[&](char c) {
|
||||
if (length < sizeof(name)) {
|
||||
name[length] = c;
|
||||
}
|
||||
length++;
|
||||
},
|
||||
[&]() {
|
||||
auto mode = length <= ColorModeNames::MAX_LENGTH ? parse_color_mode(StringRef(name, length)) : nullopt;
|
||||
if (mode.has_value()) {
|
||||
modes.insert(*mode);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s': Unknown color mode in '%s'", this->entity_id_, value.c_str());
|
||||
}
|
||||
length = 0;
|
||||
});
|
||||
if (!valid) {
|
||||
ESP_LOGW(TAG, "'%s': Can't parse 'supported_color_modes' value '%s'", this->entity_id_, value.c_str());
|
||||
return;
|
||||
}
|
||||
if (modes.empty()) {
|
||||
ESP_LOGW(TAG, "'%s': No color mode found in '%s', keeping the current ones", this->entity_id_, value.c_str());
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Supported color modes retrieved: %s", this->entity_id_, value.c_str());
|
||||
this->traits_.set_supported_color_modes(modes);
|
||||
// Attributes that arrived before the traits were validated against empty ones; apply them again
|
||||
this->schedule_apply_();
|
||||
});
|
||||
// Home Assistant reports the temperature range in Kelvin, the light core works in mireds
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "min_color_temp_kelvin", [this](StringRef value) {
|
||||
if (auto kelvin = this->parse_integer_("min_color_temp_kelvin", value, UINT16_MAX)) {
|
||||
this->traits_.set_max_mireds(1e6f / *kelvin);
|
||||
this->schedule_apply_();
|
||||
}
|
||||
});
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "max_color_temp_kelvin", [this](StringRef value) {
|
||||
if (auto kelvin = this->parse_integer_("max_color_temp_kelvin", value, UINT16_MAX)) {
|
||||
this->traits_.set_min_mireds(1e6f / *kelvin);
|
||||
this->schedule_apply_();
|
||||
}
|
||||
});
|
||||
|
||||
// An attribute is "None" while the light is off or does not report it; the last value stays
|
||||
server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](StringRef value) {
|
||||
if (is_unset(value)) {
|
||||
return; // normal while Home Assistant or the light is starting up or offline
|
||||
}
|
||||
auto on_off = parse_on_off(value.c_str());
|
||||
if (on_off != PARSE_ON && on_off != PARSE_OFF) {
|
||||
ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str());
|
||||
return;
|
||||
}
|
||||
const bool state = on_off == PARSE_ON;
|
||||
this->on_report_(this->imported_.state != state);
|
||||
this->imported_.state = state;
|
||||
});
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) {
|
||||
if (is_unset(value)) {
|
||||
return;
|
||||
}
|
||||
auto mode = parse_color_mode(value);
|
||||
if (!mode.has_value()) {
|
||||
ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str());
|
||||
return;
|
||||
}
|
||||
this->on_report_(this->imported_.color_mode != *mode);
|
||||
this->imported_.color_mode = *mode;
|
||||
});
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) {
|
||||
if (auto brightness = this->parse_integer_("brightness", value, 255)) {
|
||||
this->on_report_(this->imported_.brightness != *brightness);
|
||||
this->imported_.brightness = *brightness;
|
||||
}
|
||||
});
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) {
|
||||
if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) {
|
||||
this->on_report_(this->imported_.color_temp_kelvin != *kelvin);
|
||||
this->imported_.color_temp_kelvin = *kelvin;
|
||||
}
|
||||
});
|
||||
server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) {
|
||||
if (is_unset(value)) {
|
||||
return;
|
||||
}
|
||||
std::array<uint8_t, 3> rgb;
|
||||
if (!parse_rgb(value.c_str(), rgb)) {
|
||||
ESP_LOGE(TAG, "'%s': Can't parse 'rgb_color' value '%s'!", this->entity_id_, value.c_str());
|
||||
return;
|
||||
}
|
||||
this->on_report_(!this->imported_.has_rgb || this->imported_.rgb != rgb);
|
||||
this->imported_.rgb = rgb;
|
||||
this->imported_.has_rgb = true;
|
||||
});
|
||||
}
|
||||
|
||||
void HomeassistantLight::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Homeassistant Light:\n"
|
||||
" Entity ID: '%s'",
|
||||
this->entity_id_);
|
||||
}
|
||||
|
||||
optional<uint16_t> HomeassistantLight::parse_integer_(const char *name, StringRef value, uint16_t max) {
|
||||
if (is_unset(value)) {
|
||||
return nullopt;
|
||||
}
|
||||
auto number = parse_number<float>(value.c_str());
|
||||
if (!number.has_value()) {
|
||||
ESP_LOGE(TAG, "'%s': Can't convert '%s' value '%s' to number!", this->entity_id_, name, value.c_str());
|
||||
return nullopt;
|
||||
}
|
||||
// Written so that NaN fails too
|
||||
const bool in_range = *number >= 1.0f && *number <= max;
|
||||
if (!in_range) {
|
||||
ESP_LOGW(TAG, "'%s': '%s' value %.0f is out of range!", this->entity_id_, name, *number);
|
||||
return nullopt;
|
||||
}
|
||||
return static_cast<uint16_t>(*number);
|
||||
}
|
||||
|
||||
void HomeassistantLight::on_report_(bool changed) {
|
||||
// A report that changed an attribute answers the change that armed the echo timeout; a repeated
|
||||
// value (or a trait) does not, so a refused change still gets its warning
|
||||
if (changed && this->echo_pending_) {
|
||||
this->echo_pending_ = false;
|
||||
this->cancel_timeout(ECHO_TIMEOUT_ID);
|
||||
}
|
||||
this->report_changed_ |= changed;
|
||||
this->schedule_apply_();
|
||||
}
|
||||
|
||||
void HomeassistantLight::schedule_apply_() {
|
||||
// One update changes several attributes, each arriving as its own message; apply them as one call
|
||||
if (!this->apply_scheduled_) {
|
||||
this->apply_scheduled_ = true;
|
||||
this->defer([this]() { this->apply_imported_(); });
|
||||
}
|
||||
}
|
||||
|
||||
void HomeassistantLight::apply_imported_() {
|
||||
this->apply_scheduled_ = false;
|
||||
// Nothing to apply before the first state; while a sent change waits for its report the reported
|
||||
// state is the old one, so a trait that arrives then does not bring it back
|
||||
if (!this->imported_.state.has_value() || this->echo_pending_) {
|
||||
return;
|
||||
}
|
||||
// Attributes Home Assistant has not reported keep what the light has. An "off" report carries
|
||||
// no levels, so the ones reported last stay with the light: a turn_on without a brightness
|
||||
// then sends what Home Assistant last had, not a level that never reached it
|
||||
const Attributes &imported = this->imported_;
|
||||
LightColorValues values = this->state_->remote_values;
|
||||
values.set_state(*imported.state);
|
||||
if (imported.color_mode != ColorMode::UNKNOWN) {
|
||||
values.set_color_mode(imported.color_mode);
|
||||
}
|
||||
if (imported.brightness != 0) {
|
||||
values.set_brightness(imported.brightness / 255.0f);
|
||||
}
|
||||
// Home Assistant reports rgb_color in every mode and keeps the attributes of the other
|
||||
// modes, so only the ones of the mode it reports are taken over
|
||||
const ColorMode mode = values.get_color_mode();
|
||||
if (mode == ColorMode::COLOR_TEMPERATURE) {
|
||||
values.set_color_temperature_kelvin(imported.color_temp_kelvin); // ignores an unreported 0
|
||||
} else if (mode == ColorMode::RGB) {
|
||||
if (imported.has_rgb) {
|
||||
values.set_red(imported.rgb[0] / 255.0f);
|
||||
values.set_green(imported.rgb[1] / 255.0f);
|
||||
values.set_blue(imported.rgb[2] / 255.0f);
|
||||
}
|
||||
values.set_color_brightness(1.0f); // brightness is the only level Home Assistant has
|
||||
} else if (mode == ColorMode::WHITE) {
|
||||
values.set_white(1.0f);
|
||||
}
|
||||
if (!this->state_->set_external_state(values)) {
|
||||
// A report that changed something during a transition or flash is applied once it ends, in
|
||||
// place of the values it ends at; a trait or a repeated report has nothing newer to say
|
||||
if (this->state_->is_transitioning() && this->report_changed_) {
|
||||
this->report_held_ = true;
|
||||
this->held_hash_ = state_hash(this->state_->remote_values);
|
||||
return;
|
||||
}
|
||||
// A local change still waits for LightState::loop() to send it. Home Assistant reports again
|
||||
// once that change reaches it; when it does not, the echo timeout write_state() arms brings the
|
||||
// light back to what was reported last
|
||||
ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_);
|
||||
return;
|
||||
}
|
||||
this->report_changed_ = false;
|
||||
ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_);
|
||||
}
|
||||
|
||||
uint32_t HomeassistantLight::state_hash(const LightColorValues &values) {
|
||||
uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast<uint8_t>(values.get_color_mode()));
|
||||
for (const float channel :
|
||||
{values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(),
|
||||
values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) {
|
||||
hash = fnv1a_hash_extend(hash, to_uint8_scale(channel));
|
||||
}
|
||||
return fnv1a_hash_extend(hash, static_cast<uint16_t>(values.get_color_temperature())); // mireds exceed a byte
|
||||
}
|
||||
|
||||
void HomeassistantLight::update_state(LightState *state) {
|
||||
// The light core's setup performs one call for the initial state; a second one before the first
|
||||
// write (an on_boot automation) is a local change merged into that write, so it must be sent
|
||||
if (this->initial_write_) {
|
||||
if (this->initial_seen_) {
|
||||
this->initial_write_ = false;
|
||||
}
|
||||
this->initial_seen_ = true;
|
||||
}
|
||||
// A call without a transition replaces whatever ran, so a report held for it is older than the call
|
||||
if (!state->is_transitioning()) {
|
||||
this->report_held_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool HomeassistantLight::is_imported_(const Attributes &outgoing) const {
|
||||
const Attributes &imported = this->imported_;
|
||||
if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) {
|
||||
return false;
|
||||
}
|
||||
if (!*outgoing.state) {
|
||||
return true;
|
||||
}
|
||||
if (imported.color_mode != outgoing.color_mode || imported.brightness != outgoing.brightness) {
|
||||
return false;
|
||||
}
|
||||
// rgb_color stays reported in the other modes, so only the attributes of the mode count
|
||||
switch (outgoing.color_mode) {
|
||||
case ColorMode::COLOR_TEMPERATURE:
|
||||
return imported.color_temp_kelvin == outgoing.color_temp_kelvin;
|
||||
case ColorMode::RGB:
|
||||
return imported.has_rgb && imported.rgb == outgoing.rgb;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void HomeassistantLight::fall_back_() {
|
||||
// Back to the state Home Assistant reported, which the light still has
|
||||
if (!this->imported_.state.has_value()) {
|
||||
ESP_LOGW(TAG, "'%s': Nothing reported yet to fall back to, the light keeps the unsent change", this->entity_id_);
|
||||
return;
|
||||
}
|
||||
this->schedule_apply_();
|
||||
}
|
||||
|
||||
void HomeassistantLight::write_state(LightState *state) {
|
||||
// A reported state is taken over without a write, so every write is a local change, except
|
||||
// the very first: the light core writes its initial state, which Home Assistant does not need
|
||||
if (this->initial_write_) {
|
||||
this->initial_write_ = false;
|
||||
return;
|
||||
}
|
||||
if (state->is_transitioning()) {
|
||||
// A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that
|
||||
// replaces the transition a report was held for retargets it and is newer than the report
|
||||
if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) {
|
||||
this->report_held_ = false;
|
||||
}
|
||||
this->transitioned_ = true;
|
||||
return;
|
||||
}
|
||||
const bool held = this->report_held_;
|
||||
const bool transitioned = this->transitioned_;
|
||||
this->report_held_ = false;
|
||||
this->transitioned_ = false;
|
||||
this->report_changed_ = false; // this change is newer than any report so far, held or dropped
|
||||
// Home Assistant changed meanwhile; its report is newer than what the transition ends at or
|
||||
// what the flash goes back to
|
||||
if (held) {
|
||||
ESP_LOGD(TAG, "'%s': Applying the report that arrived during the transition", this->entity_id_);
|
||||
this->schedule_apply_();
|
||||
return;
|
||||
}
|
||||
// What Home Assistant holds after the change. It has one level, so the channel brightness of the
|
||||
// mode folds into it
|
||||
const LightColorValues &values = state->current_values;
|
||||
Attributes outgoing;
|
||||
outgoing.state = values.is_on();
|
||||
if (values.is_on()) {
|
||||
outgoing.color_mode = values.get_color_mode();
|
||||
switch (outgoing.color_mode) {
|
||||
case ColorMode::ON_OFF:
|
||||
break;
|
||||
case ColorMode::WHITE:
|
||||
outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_white());
|
||||
break;
|
||||
case ColorMode::COLOR_TEMPERATURE:
|
||||
outgoing.brightness = to_uint8_scale(values.get_brightness());
|
||||
if (values.get_color_temperature() > 0.0f) {
|
||||
// A mired value below the reported range could give more than 65535 K
|
||||
outgoing.color_temp_kelvin = lroundf(std::min(values.get_color_temperature_kelvin(), 65535.0f));
|
||||
}
|
||||
break;
|
||||
case ColorMode::RGB:
|
||||
outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_color_brightness());
|
||||
outgoing.rgb = {to_uint8_scale(values.get_red()), to_uint8_scale(values.get_green()),
|
||||
to_uint8_scale(values.get_blue())};
|
||||
outgoing.has_rgb = true;
|
||||
break;
|
||||
default:
|
||||
outgoing.brightness = to_uint8_scale(values.get_brightness());
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Home Assistant reports only what changed, so a change that leaves it as it is gets no report:
|
||||
// nothing to send and no echo to wait for. A change still on its way may alter it first, though
|
||||
if (!this->echo_pending_ && this->is_imported_(outgoing)) {
|
||||
if (transitioned) {
|
||||
// A flash ends where it began, and Home Assistant only ever gets the end
|
||||
ESP_LOGW(TAG, "'%s': Ended at the state Home Assistant already has, nothing to send (a flash never is)",
|
||||
this->entity_id_);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "'%s': Home Assistant already has this state", this->entity_id_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!api::global_api_server->is_connected()) {
|
||||
ESP_LOGE(TAG, "'%s': No clients connected to API server, change lost", this->entity_id_);
|
||||
this->fall_back_();
|
||||
return;
|
||||
}
|
||||
#ifdef USE_API_WIZARD_LINKED_INPUTS
|
||||
if (this->entity_id_[0] == '\0') {
|
||||
ESP_LOGW(TAG, "'%s': No entity ID set yet", state->get_name().c_str());
|
||||
this->fall_back_();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// In flash on the ESP8266 and encoded from there
|
||||
static const char SERVICE_TURN_ON[] PROGMEM = "light.turn_on";
|
||||
static const char SERVICE_TURN_OFF[] PROGMEM = "light.turn_off";
|
||||
static const char ENTITY_ID_KEY[] PROGMEM = "entity_id";
|
||||
static const char BRIGHTNESS_KEY[] PROGMEM = "brightness";
|
||||
static const char WHITE_KEY[] PROGMEM = "white";
|
||||
static const char COLOR_TEMP_KELVIN_KEY[] PROGMEM = "color_temp_kelvin";
|
||||
static const char RGB_COLOR_KEY[] PROGMEM = "rgb_color";
|
||||
|
||||
// The request refers to these buffers until it is sent below
|
||||
char level_buf[4]; // up to 255
|
||||
char color_temp_buf[6]; // up to 65535
|
||||
char color_buf[16];
|
||||
|
||||
api::HomeassistantActionRequest resp;
|
||||
resp.data.init(3);
|
||||
auto &entity_id = resp.data.emplace_back();
|
||||
entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY);
|
||||
entity_id.value = ProgmemStringRef(StringRef(this->entity_id_));
|
||||
|
||||
auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) {
|
||||
auto &entry = resp.data.emplace_back();
|
||||
entry.key = key;
|
||||
entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number)));
|
||||
};
|
||||
if (!*outgoing.state) {
|
||||
resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF);
|
||||
} else {
|
||||
resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_ON);
|
||||
if (outgoing.color_mode != ColorMode::ON_OFF) {
|
||||
// The level goes under "white" in that mode, which also selects the mode
|
||||
add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY)
|
||||
: ProgmemStringRef::from_lit(BRIGHTNESS_KEY),
|
||||
level_buf, outgoing.brightness);
|
||||
}
|
||||
// Home Assistant converts the color to the light's own channels, which loses the white balance
|
||||
// of a cold/warm white light, so the color goes only when it or the mode changed (or may have,
|
||||
// while a change still waits for its report)
|
||||
const Attributes &imported = this->imported_;
|
||||
const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode;
|
||||
if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) {
|
||||
add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin);
|
||||
}
|
||||
if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) {
|
||||
// A list, which only a template renders as one
|
||||
resp.data_template.init(1);
|
||||
auto &color = resp.data_template.emplace_back();
|
||||
color.key = ProgmemStringRef::from_lit(RGB_COLOR_KEY);
|
||||
color.value =
|
||||
ProgmemStringRef(StringRef(color_buf, buf_append_printf(color_buf, sizeof(color_buf), 0, "[%u,%u,%u]",
|
||||
outgoing.rgb[0], outgoing.rgb[1], outgoing.rgb[2])));
|
||||
}
|
||||
}
|
||||
api::global_api_server->send_homeassistant_action(resp);
|
||||
// Home Assistant reports the change back in a moment, which cancels this; when it does not, the
|
||||
// change got lost on the way (no subscriber yet, the entity unavailable, the action refused)
|
||||
this->echo_pending_ = true;
|
||||
this->set_timeout(ECHO_TIMEOUT_ID, this->report_timeout_s_ * 1000u, [this]() {
|
||||
this->echo_pending_ = false;
|
||||
ESP_LOGW(TAG, "'%s': Home Assistant did not report the change back", this->entity_id_);
|
||||
this->fall_back_();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
/// A light in Home Assistant, driven from here: its state and attributes are imported over the
|
||||
/// native API, and every change made on this side is sent back as a light.turn_on or
|
||||
/// light.turn_off action.
|
||||
class HomeassistantLight final : public light::LightOutput, public Component {
|
||||
public:
|
||||
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
|
||||
/// How long a sent change may take to come back as a report before the light returns to the reported state
|
||||
void set_report_timeout(uint8_t seconds) { this->report_timeout_s_ = seconds; }
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }
|
||||
|
||||
light::LightTraits get_traits() override { return this->traits_; }
|
||||
void setup_state(light::LightState *state) override { this->state_ = state; }
|
||||
void update_state(light::LightState *state) override;
|
||||
void write_state(light::LightState *state) override;
|
||||
|
||||
protected:
|
||||
/// What Home Assistant reported last, in its own units. Each attribute arrives as its own
|
||||
/// message and stays until the next report of it, so an update may change any subset; zero,
|
||||
/// ColorMode::UNKNOWN and has_rgb say an attribute was never reported.
|
||||
struct Attributes {
|
||||
uint16_t color_temp_kelvin{0};
|
||||
uint8_t brightness{0};
|
||||
std::array<uint8_t, 3> rgb{};
|
||||
light::ColorMode color_mode{light::ColorMode::UNKNOWN};
|
||||
bool has_rgb{false};
|
||||
optional<bool> state;
|
||||
};
|
||||
|
||||
/// Parses a Home Assistant attribute as an integer from 1 to max, logging anything else
|
||||
optional<uint16_t> parse_integer_(const char *name, StringRef value, uint16_t max);
|
||||
/// A state or attribute report: answers a pending echo when it changed something, and schedules the apply
|
||||
void on_report_(bool changed);
|
||||
/// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call
|
||||
static uint32_t state_hash(const light::LightColorValues &values);
|
||||
/// Schedules apply_imported_() once for the attributes of one update
|
||||
void schedule_apply_();
|
||||
void apply_imported_();
|
||||
/// True when Home Assistant reported exactly what a change would make of it
|
||||
bool is_imported_(const Attributes &outgoing) const;
|
||||
/// Returns the light to the reported state after a change that did not reach Home Assistant
|
||||
void fall_back_();
|
||||
|
||||
const char *entity_id_{nullptr};
|
||||
light::LightState *state_{nullptr};
|
||||
light::LightTraits traits_{};
|
||||
Attributes imported_{};
|
||||
uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at
|
||||
bool apply_scheduled_ : 1 {false};
|
||||
bool initial_write_ : 1 {true}; // the light core's own first write is not a local change
|
||||
bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state
|
||||
bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back
|
||||
bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends
|
||||
bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent
|
||||
bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute
|
||||
uint8_t report_timeout_s_{10};
|
||||
};
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -303,6 +303,16 @@ def test_entity_id_is_optional_only_for_wizard_inputs(
|
||||
), errors
|
||||
|
||||
|
||||
def test_light_is_a_wizard_input(tmp_path: Path) -> None:
|
||||
entities = "light:\n - platform: homeassistant\n id: ha_light\n"
|
||||
errors = config_errors(
|
||||
write_config(
|
||||
tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_light"), entities
|
||||
)
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -63,3 +63,9 @@ button:
|
||||
id: ha_button
|
||||
name: HA Button
|
||||
entity_id: button.restart
|
||||
|
||||
light:
|
||||
- platform: homeassistant
|
||||
id: ha_light
|
||||
name: HA Light
|
||||
entity_id: light.kitchen
|
||||
|
||||
@@ -15,12 +15,12 @@ def test_mixed_entities_state_subscription_count(
|
||||
generate_main(DIR / "mixed_entities.yaml")
|
||||
|
||||
# sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1)
|
||||
# + select (2) + number (4) + text (4) + button (0) = 15
|
||||
# + select (2) + number (4) + text (4) + button (0) + light (8) = 23
|
||||
define_match = [
|
||||
d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT"
|
||||
]
|
||||
assert len(define_match) == 1
|
||||
assert str(define_match[0].value) == "15"
|
||||
assert str(define_match[0].value) == "23"
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ switch:
|
||||
entity_id: switch.my_cool_switch
|
||||
id: my_cool_switch
|
||||
|
||||
light:
|
||||
- platform: homeassistant
|
||||
entity_id: light.my_cool_light
|
||||
id: ha_my_cool_light
|
||||
report_timeout: 30s
|
||||
|
||||
binary_sensor:
|
||||
- platform: homeassistant
|
||||
entity_id: binary_sensor.hello_world
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
esphome:
|
||||
name: ha-light
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: set_force_off
|
||||
variables:
|
||||
value: bool
|
||||
then:
|
||||
- globals.set:
|
||||
id: force_off
|
||||
value: !lambda return value;
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: force_off
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
|
||||
light:
|
||||
- platform: homeassistant
|
||||
name: HA Light
|
||||
id: ha_light
|
||||
entity_id: light.kitchen
|
||||
internal: false
|
||||
report_timeout: 2s
|
||||
on_turn_off:
|
||||
- logger.log: "turned off"
|
||||
on_turn_on:
|
||||
# An automation reacting to a state reported by Home Assistant with a local change
|
||||
- if:
|
||||
condition:
|
||||
lambda: return id(force_off);
|
||||
then:
|
||||
- light.turn_off: ha_light
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Test the homeassistant light platform against a fake Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
EntityState,
|
||||
HomeassistantServiceCall,
|
||||
LightColorCapability,
|
||||
LightState,
|
||||
UserService,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
ENTITY = "light.kitchen"
|
||||
SUBSCRIPTIONS = {
|
||||
(ENTITY, None),
|
||||
(ENTITY, "supported_color_modes"),
|
||||
(ENTITY, "min_color_temp_kelvin"),
|
||||
(ENTITY, "max_color_temp_kelvin"),
|
||||
(ENTITY, "color_mode"),
|
||||
(ENTITY, "brightness"),
|
||||
(ENTITY, "color_temp_kelvin"),
|
||||
(ENTITY, "rgb_color"),
|
||||
}
|
||||
COLOR_TEMPERATURE = (
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.COLOR_TEMPERATURE
|
||||
)
|
||||
RGB = (
|
||||
LightColorCapability.ON_OFF
|
||||
| LightColorCapability.BRIGHTNESS
|
||||
| LightColorCapability.RGB
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeHomeAssistant:
|
||||
"""The light entity as the device sees it over the API, and what the device sends back."""
|
||||
|
||||
client: APIClient
|
||||
key: int
|
||||
services: list[UserService]
|
||||
waiter: LineWaiter
|
||||
calls: asyncio.Queue[HomeassistantServiceCall]
|
||||
states: asyncio.Queue[LightState]
|
||||
|
||||
async def next_call(self) -> HomeassistantServiceCall:
|
||||
return await asyncio.wait_for(self.calls.get(), timeout=5.0)
|
||||
|
||||
async def next_state(self, matches: Callable[[LightState], bool]) -> LightState:
|
||||
async with asyncio.timeout(5.0):
|
||||
while not matches(state := await self.states.get()):
|
||||
pass
|
||||
return state
|
||||
|
||||
def report(self, attribute: str | None, value: str) -> None:
|
||||
self.client.send_home_assistant_state(ENTITY, attribute, value)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def light_entity(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
modes: str,
|
||||
) -> AsyncIterator[FakeHomeAssistant]:
|
||||
"""Boots the device and answers its subscriptions with a light of the given color modes."""
|
||||
loop = asyncio.get_running_loop()
|
||||
waiter = LineWaiter()
|
||||
subscribed: set[tuple[str, str | None]] = set()
|
||||
all_subscribed = loop.create_future()
|
||||
calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue()
|
||||
states: asyncio.Queue[LightState] = asyncio.Queue()
|
||||
|
||||
def on_state_sub(entity_id: str, attribute: str | None) -> None:
|
||||
subscribed.add((entity_id, attribute or None))
|
||||
if not all_subscribed.done() and subscribed >= SUBSCRIPTIONS:
|
||||
all_subscribed.set_result(None)
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState):
|
||||
states.put_nowait(state)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, services = await client.list_entities_services()
|
||||
key = next(e.key for e in entities if e.name == "HA Light")
|
||||
client.subscribe_service_calls(calls.put_nowait)
|
||||
client.subscribe_states(on_state)
|
||||
client.subscribe_home_assistant_states(on_state_sub)
|
||||
try:
|
||||
await asyncio.wait_for(all_subscribed, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}")
|
||||
ha = FakeHomeAssistant(client, key, services, waiter, calls, states)
|
||||
ha.report("supported_color_modes", modes)
|
||||
ha.report("min_color_temp_kelvin", "2000")
|
||||
ha.report("max_color_temp_kelvin", "6500")
|
||||
await waiter.wait_for("Supported color modes retrieved", timeout=5.0)
|
||||
yield ha
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_homeassistant_light(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Imported attributes, echo suppression and the actions sent for local changes."""
|
||||
async with light_entity(
|
||||
yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']"
|
||||
) as ha:
|
||||
set_force_off = next(s for s in ha.services if s.name == "set_force_off")
|
||||
# Normal while Home Assistant starts, so no warning
|
||||
ha.report(None, "unavailable")
|
||||
ha.report(None, "unknown")
|
||||
# Rejected, so the color stays unknown and the warnings name the attribute
|
||||
ha.report("rgb_color", "(127.5, 64.0, 0.0)")
|
||||
ha.report("rgb_color", "(0, 256, 0)")
|
||||
ha.report("color_temp_kelvin", "0")
|
||||
await ha.waiter.wait_for(
|
||||
"'color_temp_kelvin' value 0 is out of range", timeout=5.0
|
||||
)
|
||||
assert sum("Can't parse 'rgb_color'" in line for line in ha.waiter.lines) == 2
|
||||
assert not any("as state!" in line for line in ha.waiter.lines)
|
||||
|
||||
# A report arrives as several attribute messages and is applied as one change
|
||||
ha.report(None, "on")
|
||||
ha.report("color_mode", "rgb")
|
||||
ha.report("brightness", "128")
|
||||
ha.report("rgb_color", "(255, 0, 0)")
|
||||
state = await ha.next_state(lambda s: s.state)
|
||||
assert state.color_mode == RGB
|
||||
assert state.brightness == pytest.approx(128 / 255)
|
||||
assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0)
|
||||
# A rejected color leaves the reported one untouched
|
||||
ha.report("rgb_color", "(0, 256, 0)")
|
||||
ha.report("brightness", "255")
|
||||
state = await ha.next_state(lambda s: s.brightness == 1.0)
|
||||
assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0)
|
||||
|
||||
# The attributes of other modes stay with their mode, whatever order they arrive in
|
||||
ha.report("color_mode", "color_temp")
|
||||
ha.report("color_temp_kelvin", "4000")
|
||||
state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE)
|
||||
assert state.color_temperature == 250.0
|
||||
ha.report("rgb_color", "(0, 255, 0)")
|
||||
ha.report("brightness", "200")
|
||||
state = await ha.next_state(lambda s: s.brightness == pytest.approx(200 / 255))
|
||||
assert state.color_mode == COLOR_TEMPERATURE
|
||||
|
||||
# Local changes go to Home Assistant with its attribute names and scales; the color
|
||||
# temperature it reported is not repeated
|
||||
ha.client.light_command(ha.key, brightness=0.5)
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
|
||||
assert not call.data_template
|
||||
ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0))
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
|
||||
assert call.data_template == {"rgb_color": "[0,0,255]"}
|
||||
|
||||
# Home Assistant echoes the result; that is not sent back. Returning to the state it
|
||||
# reported last is a local change and is sent.
|
||||
ha.report(None, "off")
|
||||
ha.report("brightness", "None")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
await ha.waiter.wait_for(
|
||||
"turned off", timeout=5.0
|
||||
) # a reported state runs the triggers too
|
||||
ha.client.light_command(ha.key, state=True)
|
||||
assert (await ha.next_call()).service == "light.turn_on"
|
||||
ha.client.light_command(ha.key, state=False)
|
||||
assert (await ha.next_call()).service == "light.turn_off"
|
||||
|
||||
# Without a report of the change within report_timeout, the light goes back to the reported state
|
||||
ha.report(None, "on")
|
||||
await ha.next_state(lambda s: s.state)
|
||||
ha.client.light_command(ha.key, state=False)
|
||||
assert (await ha.next_call()).service == "light.turn_off"
|
||||
await ha.next_state(lambda s: s.state)
|
||||
await ha.waiter.wait_for("did not report the change back", timeout=5.0)
|
||||
ha.report(None, "off")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
|
||||
# The fallback also drops the levels of the unsent change: the light keeps the brightness
|
||||
# Home Assistant reported last, which a later turn_on without a brightness would send
|
||||
ha.client.light_command(ha.key, state=True, brightness=0.4)
|
||||
call = await ha.next_call()
|
||||
assert call.data["brightness"] == "102"
|
||||
state = await ha.next_state(lambda s: not s.state)
|
||||
assert state.brightness == pytest.approx(200 / 255)
|
||||
|
||||
# An automation changing the light while a report is applied is a local change too
|
||||
await ha.client.execute_service(set_force_off, {"value": True})
|
||||
ha.report(None, "on")
|
||||
assert (await ha.next_call()).service == "light.turn_off"
|
||||
await ha.client.execute_service(set_force_off, {"value": False})
|
||||
ha.report(None, "off")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
|
||||
# A change that leaves Home Assistant as it is gets no report, so none is sent
|
||||
ha.client.light_command(ha.key, state=False)
|
||||
await ha.waiter.wait_for("already has this state", timeout=5.0)
|
||||
|
||||
# A trait that arrives while a change waits for its report is not that report, and does
|
||||
# not bring the old state back early either
|
||||
ha.client.light_command(ha.key, state=True)
|
||||
assert (await ha.next_call()).service == "light.turn_on"
|
||||
await ha.next_state(lambda s: s.state)
|
||||
ha.report("max_color_temp_kelvin", "6500")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
assert (
|
||||
sum("did not report the change back" in line for line in ha.waiter.lines)
|
||||
== 3
|
||||
)
|
||||
|
||||
# A report that arrives during a flash is applied when the flash ends instead of being undone
|
||||
ha.client.light_command(ha.key, state=True, flash_length=1.0)
|
||||
ha.report(None, "on")
|
||||
ha.report("brightness", "77")
|
||||
state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255))
|
||||
assert state.state
|
||||
await ha.waiter.wait_for("arrived during the transition", timeout=5.0)
|
||||
|
||||
# A trait or a repeated report during a local transition is nothing new, so the change the
|
||||
# transition ends at is still sent
|
||||
ha.client.light_command(ha.key, state=False, transition_length=1.0)
|
||||
ha.report("max_color_temp_kelvin", "6500")
|
||||
ha.report(None, "on")
|
||||
assert (await ha.next_call()).service == "light.turn_off"
|
||||
ha.report(None, "off")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
|
||||
# A newer local command wins over a report held during a transition
|
||||
ha.client.light_command(ha.key, state=True, transition_length=1.0)
|
||||
ha.report("brightness", "50")
|
||||
await asyncio.sleep(0.1) # held before the command arrives
|
||||
ha.client.light_command(ha.key, state=True, brightness=1.0)
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data["brightness"] == "255"
|
||||
ha.report(None, "on")
|
||||
ha.report("brightness", "255")
|
||||
await ha.next_state(lambda s: s.state and s.brightness == 1.0)
|
||||
|
||||
# Also when it changes only the color, at the level the transition was heading for
|
||||
ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0)
|
||||
ha.report("brightness", "50")
|
||||
await asyncio.sleep(0.1)
|
||||
ha.client.light_command(ha.key, color_temperature=400.0)
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data == {
|
||||
"entity_id": ENTITY,
|
||||
"brightness": "128",
|
||||
"color_temp_kelvin": "2500",
|
||||
}
|
||||
ha.report("brightness", "128")
|
||||
ha.report("color_temp_kelvin", "2500")
|
||||
await ha.next_state(lambda s: s.color_temperature == 400.0)
|
||||
|
||||
# Or when it is a transition itself, replacing the one the report was held for
|
||||
ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0)
|
||||
ha.report("brightness", "50")
|
||||
await asyncio.sleep(0.1)
|
||||
ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0)
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data == {
|
||||
"entity_id": ENTITY,
|
||||
"brightness": "255",
|
||||
"color_temp_kelvin": "4000",
|
||||
}
|
||||
ha.report("brightness", "255")
|
||||
ha.report("color_temp_kelvin", "4000")
|
||||
await ha.next_state(lambda s: s.color_temperature == 250.0)
|
||||
|
||||
# Or a transition back to where the one the report was held for started
|
||||
ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0)
|
||||
ha.report("brightness", "50")
|
||||
await asyncio.sleep(0.1)
|
||||
ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0)
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data["brightness"] == "255"
|
||||
ha.report("brightness", "255")
|
||||
await ha.next_state(lambda s: s.brightness == 1.0)
|
||||
|
||||
# And when it goes back to the state the transition started from
|
||||
ha.client.light_command(ha.key, state=False)
|
||||
assert (await ha.next_call()).service == "light.turn_off"
|
||||
ha.report(None, "off")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
ha.client.light_command(ha.key, state=True, transition_length=1.0)
|
||||
ha.report(None, "on")
|
||||
ha.report("brightness", "60")
|
||||
await asyncio.sleep(0.1)
|
||||
ha.client.light_command(ha.key, state=False)
|
||||
assert (await ha.next_call()).service == "light.turn_off"
|
||||
ha.report(None, "off")
|
||||
await ha.next_state(lambda s: not s.state)
|
||||
|
||||
# A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told
|
||||
ha.client.light_command(ha.key, state=True, flash_length=0.5)
|
||||
await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0)
|
||||
|
||||
assert ha.calls.empty()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.shared_yaml("api_homeassistant_light")
|
||||
async def test_api_homeassistant_light_rgbww(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A cold/warm white light keeps its white balance: the color goes only when it changes."""
|
||||
async with light_entity(
|
||||
yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgbww']"
|
||||
) as ha:
|
||||
# Home Assistant reports rgb_color in every color mode, so the light is an RGB one here
|
||||
ha.report(None, "on")
|
||||
ha.report("color_mode", "rgbww")
|
||||
ha.report("brightness", "200")
|
||||
ha.report("rgb_color", "(255, 180, 120)")
|
||||
state = await ha.next_state(lambda s: s.state)
|
||||
assert state.color_mode == RGB
|
||||
assert state.brightness == pytest.approx(200 / 255)
|
||||
assert (state.red, state.green, state.blue) == pytest.approx(
|
||||
(1.0, 180 / 255, 120 / 255)
|
||||
)
|
||||
|
||||
# A level change sends the level alone; Home Assistant derived the color it reports from
|
||||
# the white balance and would derive a different balance from the color sent back
|
||||
ha.client.light_command(ha.key, brightness=0.5)
|
||||
call = await ha.next_call()
|
||||
assert call.service == "light.turn_on"
|
||||
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
|
||||
assert not call.data_template
|
||||
ha.report("brightness", "128")
|
||||
await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255))
|
||||
|
||||
# A new color goes along
|
||||
ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0))
|
||||
call = await ha.next_call()
|
||||
assert call.data == {"entity_id": ENTITY, "brightness": "128"}
|
||||
assert call.data_template == {"rgb_color": "[0,255,0]"}
|
||||
ha.report("rgb_color", "(0, 255, 0)")
|
||||
await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0)
|
||||
|
||||
# Switching to color temperature sends it, and the level alone once there
|
||||
ha.client.light_command(ha.key, color_temperature=250.0)
|
||||
call = await ha.next_call()
|
||||
assert call.data == {
|
||||
"entity_id": ENTITY,
|
||||
"brightness": "128",
|
||||
"color_temp_kelvin": "4000",
|
||||
}
|
||||
assert not call.data_template
|
||||
ha.report("color_mode", "color_temp")
|
||||
ha.report("color_temp_kelvin", "4000")
|
||||
state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE)
|
||||
assert state.color_temperature == 250.0
|
||||
ha.client.light_command(ha.key, brightness=1.0)
|
||||
call = await ha.next_call()
|
||||
assert call.data == {"entity_id": ENTITY, "brightness": "255"}
|
||||
ha.report("brightness", "255")
|
||||
await ha.next_state(lambda s: s.brightness == 1.0)
|
||||
|
||||
# Back to the color Home Assistant still reports: the mode changed, so the color is sent
|
||||
ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0))
|
||||
call = await ha.next_call()
|
||||
assert call.data == {"entity_id": ENTITY, "brightness": "255"}
|
||||
assert call.data_template == {"rgb_color": "[0,255,0]"}
|
||||
ha.report("color_mode", "rgbww")
|
||||
await ha.next_state(lambda s: s.color_mode == RGB)
|
||||
|
||||
assert ha.calls.empty()
|
||||
Reference in New Issue
Block a user