mirror of
https://github.com/esphome/esphome.git
synced 2025-07-30 15:16:37 +00:00
Merge remote-tracking branch 'upstream/dev' into protosize_object
This commit is contained in:
commit
ab643350f5
@ -2,6 +2,7 @@
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import getpass
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
@ -335,7 +336,7 @@ def check_permissions(port):
|
||||
raise EsphomeError(
|
||||
"You do not have read or write permission on the selected serial port. "
|
||||
"To resolve this issue, you can add your user to the dialout group "
|
||||
f"by running the following command: sudo usermod -a -G dialout {os.getlogin()}. "
|
||||
f"by running the following command: sudo usermod -a -G dialout {getpass.getuser()}. "
|
||||
"You will need to log out & back in or reboot to activate the new group access."
|
||||
)
|
||||
|
||||
|
@ -53,6 +53,7 @@ SERVICE_ARG_NATIVE_TYPES = {
|
||||
CONF_ENCRYPTION = "encryption"
|
||||
CONF_BATCH_DELAY = "batch_delay"
|
||||
CONF_CUSTOM_SERVICES = "custom_services"
|
||||
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
|
||||
|
||||
|
||||
@ -119,6 +120,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
),
|
||||
cv.Optional(CONF_CUSTOM_SERVICES, default=False): cv.boolean,
|
||||
cv.Optional(CONF_HOMEASSISTANT_SERVICES, default=False): cv.boolean,
|
||||
cv.Optional(CONF_HOMEASSISTANT_STATES, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation(
|
||||
single=True
|
||||
@ -148,6 +150,9 @@ async def to_code(config):
|
||||
if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]:
|
||||
cg.add_define("USE_API_SERVICES")
|
||||
|
||||
if config[CONF_HOMEASSISTANT_SERVICES]:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
|
||||
if config[CONF_HOMEASSISTANT_STATES]:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_STATES")
|
||||
|
||||
@ -240,6 +245,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
|
||||
)
|
||||
async def homeassistant_service_to_code(config, action_id, template_arg, args):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, False)
|
||||
templ = await cg.templatable(config[CONF_ACTION], args, None)
|
||||
@ -283,6 +289,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
|
||||
)
|
||||
async def homeassistant_event_to_code(config, action_id, template_arg, args):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
serv = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, serv, True)
|
||||
templ = await cg.templatable(config[CONF_EVENT], args, None)
|
||||
|
@ -755,17 +755,19 @@ message NoiseEncryptionSetKeyResponse {
|
||||
message SubscribeHomeassistantServicesRequest {
|
||||
option (id) = 34;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES";
|
||||
}
|
||||
|
||||
message HomeassistantServiceMap {
|
||||
string key = 1;
|
||||
string value = 2;
|
||||
string value = 2 [(no_zero_copy) = true];
|
||||
}
|
||||
|
||||
message HomeassistantServiceResponse {
|
||||
option (id) = 35;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (no_delay) = true;
|
||||
option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES";
|
||||
|
||||
string service = 1;
|
||||
repeated HomeassistantServiceMap data = 2;
|
||||
|
@ -1539,19 +1539,18 @@ void APIConnection::execute_service(const ExecuteServiceRequest &msg) {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) {
|
||||
psk_t psk{};
|
||||
NoiseEncryptionSetKeyResponse resp;
|
||||
resp.success = false;
|
||||
|
||||
psk_t psk{};
|
||||
if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) {
|
||||
ESP_LOGW(TAG, "Invalid encryption key length");
|
||||
resp.success = false;
|
||||
return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE);
|
||||
}
|
||||
if (!this->parent_->save_noise_psk(psk, true)) {
|
||||
} else if (!this->parent_->save_noise_psk(psk, true)) {
|
||||
ESP_LOGW(TAG, "Failed to save encryption key");
|
||||
resp.success = false;
|
||||
return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE);
|
||||
} else {
|
||||
resp.success = true;
|
||||
}
|
||||
resp.success = true;
|
||||
|
||||
return this->send_message(resp, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE);
|
||||
}
|
||||
#endif
|
||||
|
@ -131,11 +131,13 @@ class APIConnection : public APIServerConnection {
|
||||
void media_player_command(const MediaPlayerCommandRequest &msg) override;
|
||||
#endif
|
||||
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void send_homeassistant_service_call(const HomeassistantServiceResponse &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return;
|
||||
this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
|
||||
void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override;
|
||||
@ -209,9 +211,11 @@ class APIConnection : public APIServerConnection {
|
||||
if (msg.dump_config)
|
||||
App.schedule_dump_config();
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) override {
|
||||
this->flags_.service_call_subscription = true;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override;
|
||||
#endif
|
||||
|
@ -27,4 +27,5 @@ extend google.protobuf.MessageOptions {
|
||||
extend google.protobuf.FieldOptions {
|
||||
optional string field_ifdef = 1042;
|
||||
optional uint32 fixed_array_size = 50007;
|
||||
optional bool no_zero_copy = 50008 [default=false];
|
||||
}
|
||||
|
@ -839,13 +839,14 @@ bool NoiseEncryptionSetKeyRequest::decode_length(uint32_t field_id, ProtoLengthD
|
||||
void NoiseEncryptionSetKeyResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_bool(1, this->success); }
|
||||
void NoiseEncryptionSetKeyResponse::calculate_size(ProtoSize &size) const { size.add_bool(1, this->success); }
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void HomeassistantServiceMap::encode(ProtoWriteBuffer buffer) const {
|
||||
buffer.encode_string(1, this->key_ref_);
|
||||
buffer.encode_string(2, this->value_ref_);
|
||||
buffer.encode_string(2, this->value);
|
||||
}
|
||||
void HomeassistantServiceMap::calculate_size(ProtoSize &size) const {
|
||||
size.add_length(1, this->key_ref_.size());
|
||||
size.add_length(1, this->value_ref_.size());
|
||||
size.add_length(1, this->value.size());
|
||||
}
|
||||
void HomeassistantServiceResponse::encode(ProtoWriteBuffer buffer) const {
|
||||
buffer.encode_string(1, this->service_ref_);
|
||||
@ -867,6 +868,7 @@ void HomeassistantServiceResponse::calculate_size(ProtoSize &size) const {
|
||||
size.add_repeated_message(1, this->variables);
|
||||
size.add_bool(1, this->is_event);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void SubscribeHomeAssistantStateResponse::encode(ProtoWriteBuffer buffer) const {
|
||||
buffer.encode_string(1, this->entity_id_ref_);
|
||||
|
@ -1044,6 +1044,7 @@ class NoiseEncryptionSetKeyResponse : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
class SubscribeHomeassistantServicesRequest : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 34;
|
||||
@ -1061,8 +1062,7 @@ class HomeassistantServiceMap : public ProtoMessage {
|
||||
public:
|
||||
StringRef key_ref_{};
|
||||
void set_key(const StringRef &ref) { this->key_ref_ = ref; }
|
||||
StringRef value_ref_{};
|
||||
void set_value(const StringRef &ref) { this->value_ref_ = ref; }
|
||||
std::string value{};
|
||||
void encode(ProtoWriteBuffer buffer) const override;
|
||||
void calculate_size(ProtoSize &size) const override;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
@ -1092,6 +1092,7 @@ class HomeassistantServiceResponse : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
class SubscribeHomeAssistantStatesRequest : public ProtoDecodableMessage {
|
||||
public:
|
||||
|
@ -1038,13 +1038,14 @@ void NoiseEncryptionSetKeyRequest::dump_to(std::string &out) const {
|
||||
}
|
||||
void NoiseEncryptionSetKeyResponse::dump_to(std::string &out) const { dump_field(out, "success", this->success); }
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void SubscribeHomeassistantServicesRequest::dump_to(std::string &out) const {
|
||||
out.append("SubscribeHomeassistantServicesRequest {}");
|
||||
}
|
||||
void HomeassistantServiceMap::dump_to(std::string &out) const {
|
||||
MessageDumpHelper helper(out, "HomeassistantServiceMap");
|
||||
dump_field(out, "key", this->key_ref_);
|
||||
dump_field(out, "value", this->value_ref_);
|
||||
dump_field(out, "value", this->value);
|
||||
}
|
||||
void HomeassistantServiceResponse::dump_to(std::string &out) const {
|
||||
MessageDumpHelper helper(out, "HomeassistantServiceResponse");
|
||||
@ -1066,6 +1067,7 @@ void HomeassistantServiceResponse::dump_to(std::string &out) const {
|
||||
}
|
||||
dump_field(out, "is_event", this->is_event);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void SubscribeHomeAssistantStatesRequest::dump_to(std::string &out) const {
|
||||
out.append("SubscribeHomeAssistantStatesRequest {}");
|
||||
|
@ -149,6 +149,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: {
|
||||
SubscribeHomeassistantServicesRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@ -158,6 +159,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
|
||||
this->on_subscribe_homeassistant_services_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
case GetTimeRequest::MESSAGE_TYPE: {
|
||||
GetTimeRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@ -639,12 +641,14 @@ void APIServerConnection::on_subscribe_logs_request(const SubscribeLogsRequest &
|
||||
this->subscribe_logs(msg);
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void APIServerConnection::on_subscribe_homeassistant_services_request(
|
||||
const SubscribeHomeassistantServicesRequest &msg) {
|
||||
if (this->check_authenticated_()) {
|
||||
this->subscribe_homeassistant_services(msg);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIServerConnection::on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) {
|
||||
if (this->check_authenticated_()) {
|
||||
|
@ -60,7 +60,9 @@ class APIServerConnectionBase : public ProtoService {
|
||||
virtual void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
virtual void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
virtual void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &value){};
|
||||
@ -218,7 +220,9 @@ class APIServerConnection : public APIServerConnectionBase {
|
||||
virtual void list_entities(const ListEntitiesRequest &msg) = 0;
|
||||
virtual void subscribe_states(const SubscribeStatesRequest &msg) = 0;
|
||||
virtual void subscribe_logs(const SubscribeLogsRequest &msg) = 0;
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0;
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0;
|
||||
#endif
|
||||
@ -338,7 +342,9 @@ class APIServerConnection : public APIServerConnectionBase {
|
||||
void on_list_entities_request(const ListEntitiesRequest &msg) override;
|
||||
void on_subscribe_states_request(const SubscribeStatesRequest &msg) override;
|
||||
void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override;
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override;
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override;
|
||||
#endif
|
||||
|
@ -369,11 +369,13 @@ void APIServer::set_password(const std::string &password) { this->password_ = pa
|
||||
|
||||
void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; }
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void APIServer::send_homeassistant_service_call(const HomeassistantServiceResponse &call) {
|
||||
for (auto &client : this->clients_) {
|
||||
client->send_homeassistant_service_call(call);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIServer::subscribe_home_assistant_state(std::string entity_id, optional<std::string> attribute,
|
||||
|
@ -106,7 +106,9 @@ class APIServer : public Component, public Controller {
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
void on_media_player_update(media_player::MediaPlayer *obj) override;
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void send_homeassistant_service_call(const HomeassistantServiceResponse &call);
|
||||
#endif
|
||||
#ifdef USE_API_SERVICES
|
||||
void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); }
|
||||
#endif
|
||||
|
@ -137,6 +137,7 @@ class CustomAPIDevice {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
/** Call a Home Assistant service from ESPHome.
|
||||
*
|
||||
* Usage:
|
||||
@ -174,7 +175,7 @@ class CustomAPIDevice {
|
||||
resp.data.emplace_back();
|
||||
auto &kv = resp.data.back();
|
||||
kv.set_key(StringRef(it.first));
|
||||
kv.set_value(StringRef(it.second));
|
||||
kv.value = it.second;
|
||||
}
|
||||
global_api_server->send_homeassistant_service_call(resp);
|
||||
}
|
||||
@ -217,10 +218,11 @@ class CustomAPIDevice {
|
||||
resp.data.emplace_back();
|
||||
auto &kv = resp.data.back();
|
||||
kv.set_key(StringRef(it.first));
|
||||
kv.set_value(StringRef(it.second));
|
||||
kv.value = it.second;
|
||||
}
|
||||
global_api_server->send_homeassistant_service_call(resp);
|
||||
}
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
#include "api_server.h"
|
||||
#ifdef USE_API
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
#include "api_pb2.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@ -69,22 +70,19 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
|
||||
resp.data.emplace_back();
|
||||
auto &kv = resp.data.back();
|
||||
kv.set_key(StringRef(it.key));
|
||||
std::string value = it.value.value(x...);
|
||||
kv.set_value(StringRef(value));
|
||||
kv.value = it.value.value(x...);
|
||||
}
|
||||
for (auto &it : this->data_template_) {
|
||||
resp.data_template.emplace_back();
|
||||
auto &kv = resp.data_template.back();
|
||||
kv.set_key(StringRef(it.key));
|
||||
std::string value = it.value.value(x...);
|
||||
kv.set_value(StringRef(value));
|
||||
kv.value = it.value.value(x...);
|
||||
}
|
||||
for (auto &it : this->variables_) {
|
||||
resp.variables.emplace_back();
|
||||
auto &kv = resp.variables.back();
|
||||
kv.set_key(StringRef(it.key));
|
||||
std::string value = it.value.value(x...);
|
||||
kv.set_value(StringRef(value));
|
||||
kv.value = it.value.value(x...);
|
||||
}
|
||||
this->parent_->send_homeassistant_service_call(resp);
|
||||
}
|
||||
@ -100,3 +98,4 @@ template<typename... Ts> class HomeAssistantServiceCallAction : public Action<Ts
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif
|
||||
#endif
|
||||
|
@ -23,6 +23,7 @@ CONFIG_SCHEMA = (
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
var = await number.new_number(
|
||||
config,
|
||||
min_value=0,
|
||||
|
@ -93,14 +93,12 @@ void HomeassistantNumber::control(float value) {
|
||||
resp.data.emplace_back();
|
||||
auto &entity_id = resp.data.back();
|
||||
entity_id.set_key(ENTITY_ID_KEY);
|
||||
entity_id.set_value(StringRef(this->entity_id_));
|
||||
entity_id.value = this->entity_id_;
|
||||
|
||||
resp.data.emplace_back();
|
||||
auto &entity_value = resp.data.back();
|
||||
entity_value.set_key(VALUE_KEY);
|
||||
// to_string() returns a temporary - must store it to avoid dangling reference
|
||||
std::string value_str = to_string(value);
|
||||
entity_value.set_value(StringRef(value_str));
|
||||
entity_value.value = to_string(value);
|
||||
|
||||
api::global_api_server->send_homeassistant_service_call(resp);
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await switch.register_switch(var, config)
|
||||
|
@ -54,7 +54,7 @@ void HomeassistantSwitch::write_state(bool state) {
|
||||
resp.data.emplace_back();
|
||||
auto &entity_id_kv = resp.data.back();
|
||||
entity_id_kv.set_key(ENTITY_ID_KEY);
|
||||
entity_id_kv.set_value(StringRef(this->entity_id_));
|
||||
entity_id_kv.value = this->entity_id_;
|
||||
|
||||
api::global_api_server->send_homeassistant_service_call(resp);
|
||||
}
|
||||
|
@ -9,6 +9,28 @@ namespace light {
|
||||
|
||||
static const char *const TAG = "light";
|
||||
|
||||
// Helper functions to reduce code size for logging
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
static void log_validation_warning(const char *name, const char *param_name, float val, float min, float max) {
|
||||
ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, param_name, val, min, max);
|
||||
}
|
||||
|
||||
static void log_feature_not_supported(const char *name, const char *feature) {
|
||||
ESP_LOGW(TAG, "'%s': %s not supported", name, feature);
|
||||
}
|
||||
|
||||
static void log_color_mode_not_supported(const char *name, const char *feature) {
|
||||
ESP_LOGW(TAG, "'%s': color mode does not support setting %s", name, feature);
|
||||
}
|
||||
|
||||
static void log_invalid_parameter(const char *name, const char *message) { ESP_LOGW(TAG, "'%s': %s", name, message); }
|
||||
#else
|
||||
#define log_validation_warning(name, param_name, val, min, max)
|
||||
#define log_feature_not_supported(name, feature)
|
||||
#define log_color_mode_not_supported(name, feature)
|
||||
#define log_invalid_parameter(name, message)
|
||||
#endif
|
||||
|
||||
// Macro to reduce repetitive setter code
|
||||
#define IMPLEMENT_LIGHT_CALL_SETTER(name, type, flag) \
|
||||
LightCall &LightCall::set_##name(optional<type>(name)) { \
|
||||
@ -44,11 +66,21 @@ static const LogString *color_mode_to_human(ColorMode color_mode) {
|
||||
return LOG_STR("");
|
||||
}
|
||||
|
||||
// Helper to log percentage values
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
|
||||
static void log_percent(const char *name, const char *param, float value) {
|
||||
ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f);
|
||||
}
|
||||
#else
|
||||
#define log_percent(name, param, value)
|
||||
#endif
|
||||
|
||||
void LightCall::perform() {
|
||||
const char *name = this->parent_->get_name().c_str();
|
||||
LightColorValues v = this->validate_();
|
||||
const bool publish = this->get_publish_();
|
||||
|
||||
if (this->get_publish_()) {
|
||||
if (publish) {
|
||||
ESP_LOGD(TAG, "'%s' Setting:", name);
|
||||
|
||||
// Only print color mode when it's being changed
|
||||
@ -66,11 +98,11 @@ void LightCall::perform() {
|
||||
}
|
||||
|
||||
if (this->has_brightness()) {
|
||||
ESP_LOGD(TAG, " Brightness: %.0f%%", v.get_brightness() * 100.0f);
|
||||
log_percent(name, "Brightness", v.get_brightness());
|
||||
}
|
||||
|
||||
if (this->has_color_brightness()) {
|
||||
ESP_LOGD(TAG, " Color brightness: %.0f%%", v.get_color_brightness() * 100.0f);
|
||||
log_percent(name, "Color brightness", v.get_color_brightness());
|
||||
}
|
||||
if (this->has_red() || this->has_green() || this->has_blue()) {
|
||||
ESP_LOGD(TAG, " Red: %.0f%%, Green: %.0f%%, Blue: %.0f%%", v.get_red() * 100.0f, v.get_green() * 100.0f,
|
||||
@ -78,7 +110,7 @@ void LightCall::perform() {
|
||||
}
|
||||
|
||||
if (this->has_white()) {
|
||||
ESP_LOGD(TAG, " White: %.0f%%", v.get_white() * 100.0f);
|
||||
log_percent(name, "White", v.get_white());
|
||||
}
|
||||
if (this->has_color_temperature()) {
|
||||
ESP_LOGD(TAG, " Color temperature: %.1f mireds", v.get_color_temperature());
|
||||
@ -92,26 +124,26 @@ void LightCall::perform() {
|
||||
|
||||
if (this->has_flash_()) {
|
||||
// FLASH
|
||||
if (this->get_publish_()) {
|
||||
if (publish) {
|
||||
ESP_LOGD(TAG, " Flash length: %.1fs", this->flash_length_ / 1e3f);
|
||||
}
|
||||
|
||||
this->parent_->start_flash_(v, this->flash_length_, this->get_publish_());
|
||||
this->parent_->start_flash_(v, this->flash_length_, publish);
|
||||
} else if (this->has_transition_()) {
|
||||
// TRANSITION
|
||||
if (this->get_publish_()) {
|
||||
if (publish) {
|
||||
ESP_LOGD(TAG, " Transition length: %.1fs", this->transition_length_ / 1e3f);
|
||||
}
|
||||
|
||||
// Special case: Transition and effect can be set when turning off
|
||||
if (this->has_effect_()) {
|
||||
if (this->get_publish_()) {
|
||||
if (publish) {
|
||||
ESP_LOGD(TAG, " Effect: 'None'");
|
||||
}
|
||||
this->parent_->stop_effect_();
|
||||
}
|
||||
|
||||
this->parent_->start_transition_(v, this->transition_length_, this->get_publish_());
|
||||
this->parent_->start_transition_(v, this->transition_length_, publish);
|
||||
|
||||
} else if (this->has_effect_()) {
|
||||
// EFFECT
|
||||
@ -122,7 +154,7 @@ void LightCall::perform() {
|
||||
effect_s = this->parent_->effects_[this->effect_ - 1]->get_name().c_str();
|
||||
}
|
||||
|
||||
if (this->get_publish_()) {
|
||||
if (publish) {
|
||||
ESP_LOGD(TAG, " Effect: '%s'", effect_s);
|
||||
}
|
||||
|
||||
@ -133,13 +165,13 @@ void LightCall::perform() {
|
||||
this->parent_->set_immediately_(v, true);
|
||||
} else {
|
||||
// INSTANT CHANGE
|
||||
this->parent_->set_immediately_(v, this->get_publish_());
|
||||
this->parent_->set_immediately_(v, publish);
|
||||
}
|
||||
|
||||
if (!this->has_transition_()) {
|
||||
this->parent_->target_state_reached_callback_.call();
|
||||
}
|
||||
if (this->get_publish_()) {
|
||||
if (publish) {
|
||||
this->parent_->publish_state();
|
||||
}
|
||||
if (this->get_save_()) {
|
||||
@ -169,19 +201,19 @@ LightColorValues LightCall::validate_() {
|
||||
|
||||
// Brightness exists check
|
||||
if (this->has_brightness() && this->brightness_ > 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) {
|
||||
ESP_LOGW(TAG, "'%s': setting brightness not supported", name);
|
||||
log_feature_not_supported(name, "brightness");
|
||||
this->set_flag_(FLAG_HAS_BRIGHTNESS, false);
|
||||
}
|
||||
|
||||
// Transition length possible check
|
||||
if (this->has_transition_() && this->transition_length_ != 0 && !(color_mode & ColorCapability::BRIGHTNESS)) {
|
||||
ESP_LOGW(TAG, "'%s': transitions not supported", name);
|
||||
log_feature_not_supported(name, "transitions");
|
||||
this->set_flag_(FLAG_HAS_TRANSITION, false);
|
||||
}
|
||||
|
||||
// Color brightness exists check
|
||||
if (this->has_color_brightness() && this->color_brightness_ > 0.0f && !(color_mode & ColorCapability::RGB)) {
|
||||
ESP_LOGW(TAG, "'%s': color mode does not support setting RGB brightness", name);
|
||||
log_color_mode_not_supported(name, "RGB brightness");
|
||||
this->set_flag_(FLAG_HAS_COLOR_BRIGHTNESS, false);
|
||||
}
|
||||
|
||||
@ -189,7 +221,7 @@ LightColorValues LightCall::validate_() {
|
||||
if ((this->has_red() && this->red_ > 0.0f) || (this->has_green() && this->green_ > 0.0f) ||
|
||||
(this->has_blue() && this->blue_ > 0.0f)) {
|
||||
if (!(color_mode & ColorCapability::RGB)) {
|
||||
ESP_LOGW(TAG, "'%s': color mode does not support setting RGB color", name);
|
||||
log_color_mode_not_supported(name, "RGB color");
|
||||
this->set_flag_(FLAG_HAS_RED, false);
|
||||
this->set_flag_(FLAG_HAS_GREEN, false);
|
||||
this->set_flag_(FLAG_HAS_BLUE, false);
|
||||
@ -199,21 +231,21 @@ LightColorValues LightCall::validate_() {
|
||||
// White value exists check
|
||||
if (this->has_white() && this->white_ > 0.0f &&
|
||||
!(color_mode & ColorCapability::WHITE || color_mode & ColorCapability::COLD_WARM_WHITE)) {
|
||||
ESP_LOGW(TAG, "'%s': color mode does not support setting white value", name);
|
||||
log_color_mode_not_supported(name, "white value");
|
||||
this->set_flag_(FLAG_HAS_WHITE, false);
|
||||
}
|
||||
|
||||
// Color temperature exists check
|
||||
if (this->has_color_temperature() &&
|
||||
!(color_mode & ColorCapability::COLOR_TEMPERATURE || color_mode & ColorCapability::COLD_WARM_WHITE)) {
|
||||
ESP_LOGW(TAG, "'%s': color mode does not support setting color temperature", name);
|
||||
log_color_mode_not_supported(name, "color temperature");
|
||||
this->set_flag_(FLAG_HAS_COLOR_TEMPERATURE, false);
|
||||
}
|
||||
|
||||
// Cold/warm white value exists check
|
||||
if ((this->has_cold_white() && this->cold_white_ > 0.0f) || (this->has_warm_white() && this->warm_white_ > 0.0f)) {
|
||||
if (!(color_mode & ColorCapability::COLD_WARM_WHITE)) {
|
||||
ESP_LOGW(TAG, "'%s': color mode does not support setting cold/warm white value", name);
|
||||
log_color_mode_not_supported(name, "cold/warm white value");
|
||||
this->set_flag_(FLAG_HAS_COLD_WHITE, false);
|
||||
this->set_flag_(FLAG_HAS_WARM_WHITE, false);
|
||||
}
|
||||
@ -223,8 +255,7 @@ LightColorValues LightCall::validate_() {
|
||||
if (this->has_##name_()) { \
|
||||
auto val = this->name_##_; \
|
||||
if (val < (min) || val > (max)) { \
|
||||
ESP_LOGW(TAG, "'%s': %s value %.2f is out of range [%.1f - %.1f]", name, LOG_STR_LITERAL(upper_name), val, \
|
||||
(min), (max)); \
|
||||
log_validation_warning(name, LOG_STR_LITERAL(upper_name), val, (min), (max)); \
|
||||
this->name_##_ = clamp(val, (min), (max)); \
|
||||
} \
|
||||
}
|
||||
@ -288,7 +319,7 @@ LightColorValues LightCall::validate_() {
|
||||
|
||||
// Flash length check
|
||||
if (this->has_flash_() && this->flash_length_ == 0) {
|
||||
ESP_LOGW(TAG, "'%s': flash length must be greater than zero", name);
|
||||
log_invalid_parameter(name, "flash length must be greater than zero");
|
||||
this->set_flag_(FLAG_HAS_FLASH, false);
|
||||
}
|
||||
|
||||
@ -307,13 +338,13 @@ LightColorValues LightCall::validate_() {
|
||||
}
|
||||
|
||||
if (this->has_effect_() && (this->has_transition_() || this->has_flash_())) {
|
||||
ESP_LOGW(TAG, "'%s': effect cannot be used with transition/flash", name);
|
||||
log_invalid_parameter(name, "effect cannot be used with transition/flash");
|
||||
this->set_flag_(FLAG_HAS_TRANSITION, false);
|
||||
this->set_flag_(FLAG_HAS_FLASH, false);
|
||||
}
|
||||
|
||||
if (this->has_flash_() && this->has_transition_()) {
|
||||
ESP_LOGW(TAG, "'%s': flash cannot be used with transition", name);
|
||||
log_invalid_parameter(name, "flash cannot be used with transition");
|
||||
this->set_flag_(FLAG_HAS_TRANSITION, false);
|
||||
}
|
||||
|
||||
@ -330,7 +361,7 @@ LightColorValues LightCall::validate_() {
|
||||
}
|
||||
|
||||
if (this->has_transition_() && !supports_transition) {
|
||||
ESP_LOGW(TAG, "'%s': transitions not supported", name);
|
||||
log_feature_not_supported(name, "transitions");
|
||||
this->set_flag_(FLAG_HAS_TRANSITION, false);
|
||||
}
|
||||
|
||||
@ -340,7 +371,7 @@ LightColorValues LightCall::validate_() {
|
||||
bool target_state = this->has_state() ? this->state_ : v.is_on();
|
||||
if (!this->has_flash_() && !target_state) {
|
||||
if (this->has_effect_()) {
|
||||
ESP_LOGW(TAG, "'%s': cannot start effect when turning off", name);
|
||||
log_invalid_parameter(name, "cannot start effect when turning off");
|
||||
this->set_flag_(FLAG_HAS_EFFECT, false);
|
||||
} else if (this->parent_->active_effect_index_ != 0 && explicit_turn_off_request) {
|
||||
// Auto turn off effect
|
||||
@ -364,21 +395,27 @@ void LightCall::transform_parameters_() {
|
||||
// - RGBWW lights with color_interlock=true, which also sets "brightness" and
|
||||
// "color_temperature" (without color_interlock, CW/WW are set directly)
|
||||
// - Legacy Home Assistant (pre-colormode), which sets "white" and "color_temperature"
|
||||
|
||||
// Cache min/max mireds to avoid repeated calls
|
||||
const float min_mireds = traits.get_min_mireds();
|
||||
const float max_mireds = traits.get_max_mireds();
|
||||
|
||||
if (((this->has_white() && this->white_ > 0.0f) || this->has_color_temperature()) && //
|
||||
(this->color_mode_ & ColorCapability::COLD_WARM_WHITE) && //
|
||||
!(this->color_mode_ & ColorCapability::WHITE) && //
|
||||
!(this->color_mode_ & ColorCapability::COLOR_TEMPERATURE) && //
|
||||
traits.get_min_mireds() > 0.0f && traits.get_max_mireds() > 0.0f) {
|
||||
min_mireds > 0.0f && max_mireds > 0.0f) {
|
||||
ESP_LOGD(TAG, "'%s': setting cold/warm white channels using white/color temperature values",
|
||||
this->parent_->get_name().c_str());
|
||||
if (this->has_color_temperature()) {
|
||||
const float color_temp = clamp(this->color_temperature_, traits.get_min_mireds(), traits.get_max_mireds());
|
||||
const float ww_fraction =
|
||||
(color_temp - traits.get_min_mireds()) / (traits.get_max_mireds() - traits.get_min_mireds());
|
||||
const float color_temp = clamp(this->color_temperature_, min_mireds, max_mireds);
|
||||
const float range = max_mireds - min_mireds;
|
||||
const float ww_fraction = (color_temp - min_mireds) / range;
|
||||
const float cw_fraction = 1.0f - ww_fraction;
|
||||
const float max_cw_ww = std::max(ww_fraction, cw_fraction);
|
||||
this->cold_white_ = gamma_uncorrect(cw_fraction / max_cw_ww, this->parent_->get_gamma_correct());
|
||||
this->warm_white_ = gamma_uncorrect(ww_fraction / max_cw_ww, this->parent_->get_gamma_correct());
|
||||
const float gamma = this->parent_->get_gamma_correct();
|
||||
this->cold_white_ = gamma_uncorrect(cw_fraction / max_cw_ww, gamma);
|
||||
this->warm_white_ = gamma_uncorrect(ww_fraction / max_cw_ww, gamma);
|
||||
this->set_flag_(FLAG_HAS_COLD_WHITE, true);
|
||||
this->set_flag_(FLAG_HAS_WARM_WHITE, true);
|
||||
}
|
||||
@ -442,41 +479,39 @@ std::set<ColorMode> LightCall::get_suitable_color_modes_() {
|
||||
bool has_rgb = (this->has_color_brightness() && this->color_brightness_ > 0.0f) ||
|
||||
(this->has_red() || this->has_green() || this->has_blue());
|
||||
|
||||
// Build key from flags: [rgb][cwww][ct][white]
|
||||
#define KEY(white, ct, cwww, rgb) ((white) << 0 | (ct) << 1 | (cwww) << 2 | (rgb) << 3)
|
||||
#define ENTRY(white, ct, cwww, rgb, ...) \
|
||||
std::make_tuple<uint8_t, std::set<ColorMode>>(KEY(white, ct, cwww, rgb), __VA_ARGS__)
|
||||
|
||||
// Flag order: white, color temperature, cwww, rgb
|
||||
std::array<std::tuple<uint8_t, std::set<ColorMode>>, 10> lookup_table{
|
||||
ENTRY(true, false, false, false,
|
||||
{ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
|
||||
ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(false, true, false, false,
|
||||
{ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
|
||||
ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(true, true, false, false,
|
||||
{ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(false, false, true, false, {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(false, false, false, false,
|
||||
{ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB,
|
||||
ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE}),
|
||||
ENTRY(true, false, false, true,
|
||||
{ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(false, true, false, true, {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(true, true, false, true, {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(false, false, true, true, {ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
ENTRY(false, false, false, true,
|
||||
{ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE}),
|
||||
};
|
||||
uint8_t key = KEY(has_white, has_ct, has_cwww, has_rgb);
|
||||
|
||||
auto key = KEY(has_white, has_ct, has_cwww, has_rgb);
|
||||
for (auto &item : lookup_table) {
|
||||
if (std::get<0>(item) == key)
|
||||
return std::get<1>(item);
|
||||
switch (key) {
|
||||
case KEY(true, false, false, false): // white only
|
||||
return {ColorMode::WHITE, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
|
||||
ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(false, true, false, false): // ct only
|
||||
return {ColorMode::COLOR_TEMPERATURE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE,
|
||||
ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(true, true, false, false): // white + ct
|
||||
return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(false, false, true, false): // cwww only
|
||||
return {ColorMode::COLD_WARM_WHITE, ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(false, false, false, false): // none
|
||||
return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE, ColorMode::RGB,
|
||||
ColorMode::WHITE, ColorMode::COLOR_TEMPERATURE, ColorMode::COLD_WARM_WHITE};
|
||||
case KEY(true, false, false, true): // rgb + white
|
||||
return {ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(false, true, false, true): // rgb + ct
|
||||
case KEY(true, true, false, true): // rgb + white + ct
|
||||
return {ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(false, false, true, true): // rgb + cwww
|
||||
return {ColorMode::RGB_COLD_WARM_WHITE};
|
||||
case KEY(false, false, false, true): // rgb only
|
||||
return {ColorMode::RGB, ColorMode::RGB_WHITE, ColorMode::RGB_COLOR_TEMPERATURE, ColorMode::RGB_COLD_WARM_WHITE};
|
||||
default:
|
||||
return {}; // conflicting flags
|
||||
}
|
||||
|
||||
// This happens if there are conflicting flags given.
|
||||
return {};
|
||||
#undef KEY
|
||||
}
|
||||
|
||||
LightCall &LightCall::set_effect(const std::string &effect) {
|
||||
|
@ -84,18 +84,23 @@ class LightColorValues {
|
||||
* @return The linearly interpolated LightColorValues.
|
||||
*/
|
||||
static LightColorValues lerp(const LightColorValues &start, const LightColorValues &end, float completion) {
|
||||
// Directly interpolate the raw values to avoid getter/setter overhead.
|
||||
// This is safe because:
|
||||
// - All LightColorValues have their values clamped when set via the setters
|
||||
// - std::lerp guarantees output is in the same range as inputs
|
||||
// - Therefore the output doesn't need clamping, so we can skip the setters
|
||||
LightColorValues v;
|
||||
v.set_color_mode(end.color_mode_);
|
||||
v.set_state(std::lerp(start.get_state(), end.get_state(), completion));
|
||||
v.set_brightness(std::lerp(start.get_brightness(), end.get_brightness(), completion));
|
||||
v.set_color_brightness(std::lerp(start.get_color_brightness(), end.get_color_brightness(), completion));
|
||||
v.set_red(std::lerp(start.get_red(), end.get_red(), completion));
|
||||
v.set_green(std::lerp(start.get_green(), end.get_green(), completion));
|
||||
v.set_blue(std::lerp(start.get_blue(), end.get_blue(), completion));
|
||||
v.set_white(std::lerp(start.get_white(), end.get_white(), completion));
|
||||
v.set_color_temperature(std::lerp(start.get_color_temperature(), end.get_color_temperature(), completion));
|
||||
v.set_cold_white(std::lerp(start.get_cold_white(), end.get_cold_white(), completion));
|
||||
v.set_warm_white(std::lerp(start.get_warm_white(), end.get_warm_white(), completion));
|
||||
v.color_mode_ = end.color_mode_;
|
||||
v.state_ = std::lerp(start.state_, end.state_, completion);
|
||||
v.brightness_ = std::lerp(start.brightness_, end.brightness_, completion);
|
||||
v.color_brightness_ = std::lerp(start.color_brightness_, end.color_brightness_, completion);
|
||||
v.red_ = std::lerp(start.red_, end.red_, completion);
|
||||
v.green_ = std::lerp(start.green_, end.green_, completion);
|
||||
v.blue_ = std::lerp(start.blue_, end.blue_, completion);
|
||||
v.white_ = std::lerp(start.white_, end.white_, completion);
|
||||
v.color_temperature_ = std::lerp(start.color_temperature_, end.color_temperature_, completion);
|
||||
v.cold_white_ = std::lerp(start.cold_white_, end.cold_white_, completion);
|
||||
v.warm_white_ = std::lerp(start.warm_white_, end.warm_white_, completion);
|
||||
return v;
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,32 @@ namespace light {
|
||||
|
||||
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
|
||||
|
||||
// Lookup table for color mode strings
|
||||
static constexpr const char *get_color_mode_json_str(ColorMode mode) {
|
||||
switch (mode) {
|
||||
case ColorMode::ON_OFF:
|
||||
return "onoff";
|
||||
case ColorMode::BRIGHTNESS:
|
||||
return "brightness";
|
||||
case ColorMode::WHITE:
|
||||
return "white"; // not supported by HA in MQTT
|
||||
case ColorMode::COLOR_TEMPERATURE:
|
||||
return "color_temp";
|
||||
case ColorMode::COLD_WARM_WHITE:
|
||||
return "cwww"; // not supported by HA
|
||||
case ColorMode::RGB:
|
||||
return "rgb";
|
||||
case ColorMode::RGB_WHITE:
|
||||
return "rgbw";
|
||||
case ColorMode::RGB_COLOR_TEMPERATURE:
|
||||
return "rgbct"; // not supported by HA
|
||||
case ColorMode::RGB_COLD_WARM_WHITE:
|
||||
return "rgbww";
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
|
||||
if (state.supports_effects())
|
||||
@ -16,60 +42,36 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
|
||||
auto values = state.remote_values;
|
||||
auto traits = state.get_output()->get_traits();
|
||||
|
||||
switch (values.get_color_mode()) {
|
||||
case ColorMode::UNKNOWN: // don't need to set color mode if we don't know it
|
||||
break;
|
||||
case ColorMode::ON_OFF:
|
||||
root["color_mode"] = "onoff";
|
||||
break;
|
||||
case ColorMode::BRIGHTNESS:
|
||||
root["color_mode"] = "brightness";
|
||||
break;
|
||||
case ColorMode::WHITE: // not supported by HA in MQTT
|
||||
root["color_mode"] = "white";
|
||||
break;
|
||||
case ColorMode::COLOR_TEMPERATURE:
|
||||
root["color_mode"] = "color_temp";
|
||||
break;
|
||||
case ColorMode::COLD_WARM_WHITE: // not supported by HA
|
||||
root["color_mode"] = "cwww";
|
||||
break;
|
||||
case ColorMode::RGB:
|
||||
root["color_mode"] = "rgb";
|
||||
break;
|
||||
case ColorMode::RGB_WHITE:
|
||||
root["color_mode"] = "rgbw";
|
||||
break;
|
||||
case ColorMode::RGB_COLOR_TEMPERATURE: // not supported by HA
|
||||
root["color_mode"] = "rgbct";
|
||||
break;
|
||||
case ColorMode::RGB_COLD_WARM_WHITE:
|
||||
root["color_mode"] = "rgbww";
|
||||
break;
|
||||
const auto color_mode = values.get_color_mode();
|
||||
const char *mode_str = get_color_mode_json_str(color_mode);
|
||||
if (mode_str != nullptr) {
|
||||
root["color_mode"] = mode_str;
|
||||
}
|
||||
|
||||
if (values.get_color_mode() & ColorCapability::ON_OFF)
|
||||
if (color_mode & ColorCapability::ON_OFF)
|
||||
root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF";
|
||||
if (values.get_color_mode() & ColorCapability::BRIGHTNESS)
|
||||
root["brightness"] = uint8_t(values.get_brightness() * 255);
|
||||
if (color_mode & ColorCapability::BRIGHTNESS)
|
||||
root["brightness"] = to_uint8_scale(values.get_brightness());
|
||||
|
||||
JsonObject color = root["color"].to<JsonObject>();
|
||||
if (values.get_color_mode() & ColorCapability::RGB) {
|
||||
color["r"] = uint8_t(values.get_color_brightness() * values.get_red() * 255);
|
||||
color["g"] = uint8_t(values.get_color_brightness() * values.get_green() * 255);
|
||||
color["b"] = uint8_t(values.get_color_brightness() * values.get_blue() * 255);
|
||||
if (color_mode & ColorCapability::RGB) {
|
||||
float color_brightness = values.get_color_brightness();
|
||||
color["r"] = to_uint8_scale(color_brightness * values.get_red());
|
||||
color["g"] = to_uint8_scale(color_brightness * values.get_green());
|
||||
color["b"] = to_uint8_scale(color_brightness * values.get_blue());
|
||||
}
|
||||
if (values.get_color_mode() & ColorCapability::WHITE) {
|
||||
color["w"] = uint8_t(values.get_white() * 255);
|
||||
root["white_value"] = uint8_t(values.get_white() * 255); // legacy API
|
||||
if (color_mode & ColorCapability::WHITE) {
|
||||
uint8_t white_val = to_uint8_scale(values.get_white());
|
||||
color["w"] = white_val;
|
||||
root["white_value"] = white_val; // legacy API
|
||||
}
|
||||
if (values.get_color_mode() & ColorCapability::COLOR_TEMPERATURE) {
|
||||
if (color_mode & ColorCapability::COLOR_TEMPERATURE) {
|
||||
// this one isn't under the color subkey for some reason
|
||||
root["color_temp"] = uint32_t(values.get_color_temperature());
|
||||
}
|
||||
if (values.get_color_mode() & ColorCapability::COLD_WARM_WHITE) {
|
||||
color["c"] = uint8_t(values.get_cold_white() * 255);
|
||||
color["w"] = uint8_t(values.get_warm_white() * 255);
|
||||
if (color_mode & ColorCapability::COLD_WARM_WHITE) {
|
||||
color["c"] = to_uint8_scale(values.get_cold_white());
|
||||
color["w"] = to_uint8_scale(values.get_warm_white());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,8 @@ void LightState::setup() {
|
||||
}
|
||||
|
||||
// When supported color temperature range is known, initialize color temperature setting within bounds.
|
||||
float min_mireds = this->get_traits().get_min_mireds();
|
||||
auto traits = this->get_traits();
|
||||
float min_mireds = traits.get_min_mireds();
|
||||
if (min_mireds > 0) {
|
||||
this->remote_values.set_color_temperature(min_mireds);
|
||||
this->current_values.set_color_temperature(min_mireds);
|
||||
@ -43,11 +44,8 @@ void LightState::setup() {
|
||||
this->rtc_ = global_preferences->make_preference<LightStateRTCState>(this->get_object_id_hash());
|
||||
// Attempt to load from preferences, else fall back to default values
|
||||
if (!this->rtc_.load(&recovered)) {
|
||||
recovered.state = false;
|
||||
if (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON ||
|
||||
this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) {
|
||||
recovered.state = true;
|
||||
}
|
||||
recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON ||
|
||||
this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON);
|
||||
} else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF ||
|
||||
this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) {
|
||||
// Inverted restore state
|
||||
@ -88,17 +86,18 @@ void LightState::setup() {
|
||||
}
|
||||
void LightState::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str());
|
||||
if (this->get_traits().supports_color_capability(ColorCapability::BRIGHTNESS)) {
|
||||
auto traits = this->get_traits();
|
||||
if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Default Transition Length: %.1fs\n"
|
||||
" Gamma Correct: %.2f",
|
||||
this->default_transition_length_ / 1e3f, this->gamma_correct_);
|
||||
}
|
||||
if (this->get_traits().supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) {
|
||||
if (traits.supports_color_capability(ColorCapability::COLOR_TEMPERATURE)) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Min Mireds: %.1f\n"
|
||||
" Max Mireds: %.1f",
|
||||
this->get_traits().get_min_mireds(), this->get_traits().get_max_mireds());
|
||||
traits.get_min_mireds(), traits.get_max_mireds());
|
||||
}
|
||||
}
|
||||
void LightState::loop() {
|
||||
|
@ -13,14 +13,13 @@ void PowerSupply::setup() {
|
||||
this->request_high_power();
|
||||
}
|
||||
void PowerSupply::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Power Supply:");
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Power Supply:\n"
|
||||
" Time to enable: %" PRIu32 " ms\n"
|
||||
" Keep on time: %.1f s",
|
||||
this->enable_time_, this->keep_on_time_ / 1000.0f);
|
||||
if (this->enable_on_boot_)
|
||||
ESP_LOGCONFIG(TAG, " Enabled at startup: True");
|
||||
" Keep on time: %" PRIu32 " s\n"
|
||||
" Enable at startup: %s",
|
||||
this->enable_time_, this->keep_on_time_ / 1000u, YESNO(this->enable_on_boot_));
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
}
|
||||
|
||||
float PowerSupply::get_setup_priority() const { return setup_priority::IO; }
|
||||
@ -30,7 +29,7 @@ bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; }
|
||||
void PowerSupply::request_high_power() {
|
||||
if (this->active_requests_ == 0) {
|
||||
this->cancel_timeout("power-supply-off");
|
||||
ESP_LOGD(TAG, "Enabling power supply.");
|
||||
ESP_LOGV(TAG, "Enabling");
|
||||
this->pin_->digital_write(true);
|
||||
delay(this->enable_time_);
|
||||
}
|
||||
@ -45,7 +44,7 @@ void PowerSupply::unrequest_high_power() {
|
||||
this->active_requests_--;
|
||||
if (this->active_requests_ == 0) {
|
||||
this->set_timeout("power-supply-off", this->keep_on_time_, [this]() {
|
||||
ESP_LOGD(TAG, "Disabling power supply.");
|
||||
ESP_LOGV(TAG, "Disabling");
|
||||
this->pin_->digital_write(false);
|
||||
});
|
||||
}
|
||||
|
@ -36,10 +36,10 @@ class PowerSupply : public Component {
|
||||
|
||||
protected:
|
||||
GPIOPin *pin_;
|
||||
bool enable_on_boot_{false};
|
||||
uint32_t enable_time_;
|
||||
uint32_t keep_on_time_;
|
||||
int16_t active_requests_{0}; // use signed integer to make catching negative requests easier.
|
||||
bool enable_on_boot_{false};
|
||||
};
|
||||
|
||||
class PowerSupplyRequester {
|
||||
|
@ -6,6 +6,7 @@ from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_
|
||||
from esphome.components.network import IPAddress
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import only_with_esp_idf
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
CONF_BSSID,
|
||||
@ -336,7 +337,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
single=True
|
||||
),
|
||||
cv.Optional(CONF_USE_PSRAM): cv.All(
|
||||
cv.requires_component("psram"), cv.boolean
|
||||
only_with_esp_idf, cv.requires_component("psram"), cv.boolean
|
||||
),
|
||||
}
|
||||
),
|
||||
|
@ -16,6 +16,7 @@
|
||||
namespace esphome {
|
||||
|
||||
static const char *const TAG = "component";
|
||||
static const char *const UNSPECIFIED_MESSAGE = "unspecified";
|
||||
|
||||
// Global vectors for component data that doesn't belong in every instance.
|
||||
// Using vector instead of unordered_map for both because:
|
||||
@ -132,7 +133,7 @@ void Component::call_dump_config() {
|
||||
this->dump_config();
|
||||
if (this->is_failed()) {
|
||||
// Look up error message from global vector
|
||||
const char *error_msg = "unspecified";
|
||||
const char *error_msg = nullptr;
|
||||
if (component_error_messages) {
|
||||
for (const auto &pair : *component_error_messages) {
|
||||
if (pair.first == this) {
|
||||
@ -141,7 +142,8 @@ void Component::call_dump_config() {
|
||||
}
|
||||
}
|
||||
}
|
||||
ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(), error_msg);
|
||||
ESP_LOGE(TAG, " %s is marked FAILED: %s", this->get_component_source(),
|
||||
error_msg ? error_msg : UNSPECIFIED_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,15 +286,15 @@ void Component::status_set_warning(const char *message) {
|
||||
return;
|
||||
this->component_state_ |= STATUS_LED_WARNING;
|
||||
App.app_state_ |= STATUS_LED_WARNING;
|
||||
ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message);
|
||||
ESP_LOGW(TAG, "%s set Warning flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE);
|
||||
}
|
||||
void Component::status_set_error(const char *message) {
|
||||
if ((this->component_state_ & STATUS_LED_ERROR) != 0)
|
||||
return;
|
||||
this->component_state_ |= STATUS_LED_ERROR;
|
||||
App.app_state_ |= STATUS_LED_ERROR;
|
||||
ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message);
|
||||
if (strcmp(message, "unspecified") != 0) {
|
||||
ESP_LOGE(TAG, "%s set Error flag: %s", this->get_component_source(), message ? message : UNSPECIFIED_MESSAGE);
|
||||
if (message != nullptr) {
|
||||
// Lazy allocate the error messages vector if needed
|
||||
if (!component_error_messages) {
|
||||
component_error_messages = std::make_unique<std::vector<std::pair<const Component *, const char *>>>();
|
||||
|
@ -202,9 +202,9 @@ class Component {
|
||||
|
||||
bool status_has_error() const;
|
||||
|
||||
void status_set_warning(const char *message = "unspecified");
|
||||
void status_set_warning(const char *message = nullptr);
|
||||
|
||||
void status_set_error(const char *message = "unspecified");
|
||||
void status_set_error(const char *message = nullptr);
|
||||
|
||||
void status_clear_warning();
|
||||
|
||||
|
@ -109,6 +109,7 @@
|
||||
#define USE_API
|
||||
#define USE_API_CLIENT_CONNECTED_TRIGGER
|
||||
#define USE_API_CLIENT_DISCONNECTED_TRIGGER
|
||||
#define USE_API_HOMEASSISTANT_SERVICES
|
||||
#define USE_API_HOMEASSISTANT_STATES
|
||||
#define USE_API_NOISE
|
||||
#define USE_API_PLAINTEXT
|
||||
|
@ -68,7 +68,10 @@ To bit_cast(const From &src) {
|
||||
return dst;
|
||||
}
|
||||
#endif
|
||||
using std::lerp;
|
||||
|
||||
// clang-format off
|
||||
inline float lerp(float completion, float start, float end) = delete; // Please use std::lerp. Notice that it has different order on arguments!
|
||||
// clang-format on
|
||||
|
||||
// std::byteswap from C++23
|
||||
template<typename T> constexpr T byteswap(T n) {
|
||||
|
@ -562,11 +562,16 @@ class StringType(TypeInfo):
|
||||
@property
|
||||
def public_content(self) -> list[str]:
|
||||
content: list[str] = []
|
||||
# Add std::string storage if message needs decoding
|
||||
if self._needs_decode:
|
||||
|
||||
# Check if no_zero_copy option is set
|
||||
no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False)
|
||||
|
||||
# Add std::string storage if message needs decoding OR if no_zero_copy is set
|
||||
if self._needs_decode or no_zero_copy:
|
||||
content.append(f"std::string {self.field_name}{{}};")
|
||||
|
||||
if self._needs_encode:
|
||||
# Only add StringRef if encoding is needed AND no_zero_copy is not set
|
||||
if self._needs_encode and not no_zero_copy:
|
||||
content.extend(
|
||||
[
|
||||
# Add StringRef field if message needs encoding
|
||||
@ -581,13 +586,28 @@ class StringType(TypeInfo):
|
||||
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);"
|
||||
# Check if no_zero_copy option is set
|
||||
no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False)
|
||||
|
||||
if no_zero_copy:
|
||||
# Use the std::string directly
|
||||
return f"buffer.encode_string({self.number}, this->{self.field_name});"
|
||||
else:
|
||||
# Use the StringRef
|
||||
return f"buffer.encode_string({self.number}, this->{self.field_name}_ref_);"
|
||||
|
||||
def dump(self, name):
|
||||
# Check if no_zero_copy option is set
|
||||
no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False)
|
||||
|
||||
# If name is 'it', this is a repeated field element - always use string
|
||||
if name == "it":
|
||||
return "append_quoted_string(out, StringRef(it));"
|
||||
|
||||
# If no_zero_copy is set, always use std::string
|
||||
if no_zero_copy:
|
||||
return f'out.append("\'").append(this->{self.field_name}).append("\'");'
|
||||
|
||||
# For SOURCE_CLIENT only, always use std::string
|
||||
if not self._needs_encode:
|
||||
return f'out.append("\'").append(this->{self.field_name}).append("\'");'
|
||||
@ -607,6 +627,13 @@ class StringType(TypeInfo):
|
||||
|
||||
@property
|
||||
def dump_content(self) -> str:
|
||||
# Check if no_zero_copy option is set
|
||||
no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False)
|
||||
|
||||
# If no_zero_copy is set, always use std::string
|
||||
if no_zero_copy:
|
||||
return f'dump_field(out, "{self.name}", this->{self.field_name});'
|
||||
|
||||
# For SOURCE_CLIENT only, use std::string
|
||||
if not self._needs_encode:
|
||||
return f'dump_field(out, "{self.name}", this->{self.field_name});'
|
||||
@ -622,8 +649,17 @@ class StringType(TypeInfo):
|
||||
return o
|
||||
|
||||
def get_size_calculation(self, name: str, force: bool = False) -> str:
|
||||
# For SOURCE_CLIENT only messages, use the string field directly
|
||||
if not self._needs_encode:
|
||||
# Check if no_zero_copy option is set
|
||||
no_zero_copy = get_field_opt(self._field, pb.no_zero_copy, False)
|
||||
|
||||
# For SOURCE_CLIENT only messages or no_zero_copy, use the string field directly
|
||||
if not self._needs_encode or no_zero_copy:
|
||||
# For no_zero_copy, we need to use .size() on the string
|
||||
if no_zero_copy and name != "it":
|
||||
field_id_size = self.calculate_field_id_size()
|
||||
return (
|
||||
f"size.add_length({field_id_size}, this->{self.field_name}.size());"
|
||||
)
|
||||
return self._get_simple_size_calculation(name, force, "add_length")
|
||||
|
||||
# Check if this is being called from a repeated field context
|
||||
|
@ -197,7 +197,7 @@ def lint_content_find_check(find, only_first=False, **kwargs):
|
||||
find_ = find(fname, content)
|
||||
errs = []
|
||||
for line, col in find_all(content, find_):
|
||||
err = func(fname)
|
||||
err = func(fname, line, col, content)
|
||||
errs.append((line + 1, col + 1, err))
|
||||
if only_first:
|
||||
break
|
||||
@ -264,12 +264,12 @@ def lint_executable_bit(fname):
|
||||
"esphome/dashboard/static/ext-searchbox.js",
|
||||
],
|
||||
)
|
||||
def lint_tabs(fname):
|
||||
def lint_tabs(fname, line, col, content):
|
||||
return "File contains tab character. Please convert tabs to spaces."
|
||||
|
||||
|
||||
@lint_content_find_check("\r", only_first=True)
|
||||
def lint_newline(fname):
|
||||
def lint_newline(fname, line, col, content):
|
||||
return "File contains Windows newline. Please set your editor to Unix newline mode."
|
||||
|
||||
|
||||
@ -512,7 +512,7 @@ def relative_cpp_search_text(fname, content):
|
||||
|
||||
|
||||
@lint_content_find_check(relative_cpp_search_text, include=["esphome/components/*.cpp"])
|
||||
def lint_relative_cpp_import(fname):
|
||||
def lint_relative_cpp_import(fname, line, col, content):
|
||||
return (
|
||||
"Component contains absolute import - Components must always use "
|
||||
"relative imports.\n"
|
||||
@ -529,6 +529,20 @@ def relative_py_search_text(fname, content):
|
||||
return f"esphome.components.{integration}"
|
||||
|
||||
|
||||
def convert_path_to_relative(abspath, current):
|
||||
"""Convert an absolute path to a relative import path."""
|
||||
if abspath == current:
|
||||
return "."
|
||||
absparts = abspath.split(".")
|
||||
curparts = current.split(".")
|
||||
uplen = len(curparts)
|
||||
while absparts and curparts and absparts[0] == curparts[0]:
|
||||
absparts.pop(0)
|
||||
curparts.pop(0)
|
||||
uplen -= 1
|
||||
return "." * uplen + ".".join(absparts)
|
||||
|
||||
|
||||
@lint_content_find_check(
|
||||
relative_py_search_text,
|
||||
include=["esphome/components/*.py"],
|
||||
@ -537,14 +551,19 @@ def relative_py_search_text(fname, content):
|
||||
"esphome/components/web_server/__init__.py",
|
||||
],
|
||||
)
|
||||
def lint_relative_py_import(fname):
|
||||
def lint_relative_py_import(fname, line, col, content):
|
||||
import_line = content.splitlines()[line]
|
||||
abspath = import_line[col:].split(" ")[0]
|
||||
current = fname.removesuffix(".py").replace(os.path.sep, ".")
|
||||
replacement = convert_path_to_relative(abspath, current)
|
||||
newline = import_line.replace(abspath, replacement)
|
||||
return (
|
||||
"Component contains absolute import - Components must always use "
|
||||
"relative imports within the integration.\n"
|
||||
"Change:\n"
|
||||
' from esphome.components.abc import abc_ns"\n'
|
||||
f" {import_line}\n"
|
||||
"to:\n"
|
||||
" from . import abc_ns\n\n"
|
||||
f" {newline}\n"
|
||||
)
|
||||
|
||||
|
||||
@ -588,7 +607,7 @@ def lint_namespace(fname, content):
|
||||
|
||||
|
||||
@lint_content_find_check('"esphome.h"', include=cpp_include, exclude=["tests/custom.h"])
|
||||
def lint_esphome_h(fname):
|
||||
def lint_esphome_h(fname, line, col, content):
|
||||
return (
|
||||
"File contains reference to 'esphome.h' - This file is "
|
||||
"auto-generated and should only be used for *custom* "
|
||||
@ -679,7 +698,7 @@ def lint_trailing_whitespace(fname, match):
|
||||
"tests/custom.h",
|
||||
],
|
||||
)
|
||||
def lint_log_in_header(fname):
|
||||
def lint_log_in_header(fname, line, col, content):
|
||||
return (
|
||||
"Found reference to ESP_LOG in header file. Using ESP_LOG* in header files "
|
||||
"is currently not possible - please move the definition to a source file (.cpp)"
|
||||
|
@ -6,7 +6,7 @@ set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
if [ ! -n "$VIRTUAL_ENV" ]; then
|
||||
if [ -x "$(command -v uv)" ]; then
|
||||
uv venv venv
|
||||
uv venv --seed venv
|
||||
else
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
5
tests/components/packages/garage-door.yaml
Normal file
5
tests/components/packages/garage-door.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
switch:
|
||||
- name: ${door_name} Garage Door Switch
|
||||
platform: gpio
|
||||
pin: ${door_pin}
|
||||
id: ${door_id}
|
19
tests/components/packages/test-vars.esp32-idf.yaml
Normal file
19
tests/components/packages/test-vars.esp32-idf.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
packages:
|
||||
left_garage_door: !include
|
||||
file: garage-door.yaml
|
||||
vars:
|
||||
door_name: Left
|
||||
door_pin: 1
|
||||
door_id: left_garage_door
|
||||
middle_garage_door: !include
|
||||
file: garage-door.yaml
|
||||
vars:
|
||||
door_name: Middle
|
||||
door_pin: 2
|
||||
door_id: middle_garage_door
|
||||
right_garage_door: !include
|
||||
file: garage-door.yaml
|
||||
vars:
|
||||
door_name: Right
|
||||
door_pin: 3
|
||||
door_id: right_garage_door
|
311
tests/integration/fixtures/api_homeassistant.yaml
Normal file
311
tests/integration/fixtures/api_homeassistant.yaml
Normal file
@ -0,0 +1,311 @@
|
||||
esphome:
|
||||
name: test-ha-api
|
||||
friendly_name: Home Assistant API Test
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
services:
|
||||
- service: trigger_all_tests
|
||||
then:
|
||||
- logger.log: "=== Starting Home Assistant API Tests ==="
|
||||
- button.press: test_basic_service
|
||||
- button.press: test_templated_service
|
||||
- button.press: test_empty_string_service
|
||||
- button.press: test_multiple_fields_service
|
||||
- button.press: test_complex_lambda_service
|
||||
- button.press: test_all_empty_service
|
||||
- button.press: test_rapid_service_calls
|
||||
- button.press: test_read_ha_states
|
||||
- number.set:
|
||||
id: ha_number
|
||||
value: 42.5
|
||||
- switch.turn_on: ha_switch
|
||||
- switch.turn_off: ha_switch
|
||||
- logger.log: "=== All tests completed ==="
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# Time component for templated values
|
||||
time:
|
||||
- platform: homeassistant
|
||||
id: homeassistant_time
|
||||
|
||||
# Global variables for testing
|
||||
globals:
|
||||
- id: test_brightness
|
||||
type: int
|
||||
initial_value: '75'
|
||||
- id: test_string
|
||||
type: std::string
|
||||
initial_value: '"test_value"'
|
||||
|
||||
# Sensors for testing state reading
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Test Sensor"
|
||||
id: test_sensor
|
||||
lambda: return 42.0;
|
||||
update_interval: 0.1s
|
||||
|
||||
# Home Assistant sensor that reads external state
|
||||
- platform: homeassistant
|
||||
name: "HA Temperature"
|
||||
entity_id: sensor.external_temperature
|
||||
id: ha_temperature
|
||||
on_value:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "HA Temperature state updated: %.1f"
|
||||
args: ['x']
|
||||
|
||||
# Test multiple HA state sensors
|
||||
- platform: homeassistant
|
||||
name: "HA Humidity"
|
||||
entity_id: sensor.external_humidity
|
||||
id: ha_humidity
|
||||
on_value:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "HA Humidity state updated: %.1f"
|
||||
args: ['x']
|
||||
|
||||
# Binary sensor from Home Assistant
|
||||
binary_sensor:
|
||||
- platform: homeassistant
|
||||
name: "HA Motion"
|
||||
entity_id: binary_sensor.external_motion
|
||||
id: ha_motion
|
||||
on_state:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "HA Motion state changed: %s"
|
||||
args: ['x ? "ON" : "OFF"']
|
||||
|
||||
# Text sensor from Home Assistant
|
||||
text_sensor:
|
||||
- platform: homeassistant
|
||||
name: "HA Weather"
|
||||
entity_id: weather.home
|
||||
attribute: condition
|
||||
id: ha_weather
|
||||
on_value:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "HA Weather condition updated: %s"
|
||||
args: ['x.c_str()']
|
||||
|
||||
# Test empty state handling
|
||||
- platform: homeassistant
|
||||
name: "HA Empty State"
|
||||
entity_id: sensor.nonexistent_sensor
|
||||
id: ha_empty_state
|
||||
on_value:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "HA Empty state updated: %s"
|
||||
args: ['x.c_str()']
|
||||
|
||||
# Number component for testing HA number control
|
||||
number:
|
||||
- platform: template
|
||||
name: "HA Controlled Number"
|
||||
id: ha_number
|
||||
min_value: 0
|
||||
max_value: 100
|
||||
step: 1
|
||||
optimistic: true
|
||||
set_action:
|
||||
- logger.log:
|
||||
format: "Setting HA number to: %.1f"
|
||||
args: ['x']
|
||||
- homeassistant.action:
|
||||
action: input_number.set_value
|
||||
data:
|
||||
entity_id: input_number.test_number
|
||||
value: !lambda 'return to_string(x);'
|
||||
|
||||
# Switch component for testing HA switch control
|
||||
switch:
|
||||
- platform: template
|
||||
name: "HA Controlled Switch"
|
||||
id: ha_switch
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- logger.log: "Toggling HA switch: switch.test_switch ON"
|
||||
- homeassistant.action:
|
||||
action: switch.turn_on
|
||||
data:
|
||||
entity_id: switch.test_switch
|
||||
turn_off_action:
|
||||
- logger.log: "Toggling HA switch: switch.test_switch OFF"
|
||||
- homeassistant.action:
|
||||
action: switch.turn_off
|
||||
data:
|
||||
entity_id: switch.test_switch
|
||||
|
||||
# Buttons for testing various service call scenarios
|
||||
button:
|
||||
# Test 1: Basic service call with static values
|
||||
- platform: template
|
||||
name: "Test Basic Service"
|
||||
id: test_basic_service
|
||||
on_press:
|
||||
- logger.log: "Sending HomeAssistant service call: light.turn_off"
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.test_light
|
||||
- logger.log: "Service data: entity_id=light.test_light"
|
||||
|
||||
# Test 2: Service call with templated/lambda values (main bug fix test)
|
||||
- platform: template
|
||||
name: "Test Templated Service"
|
||||
id: test_templated_service
|
||||
on_press:
|
||||
- logger.log: "Testing templated service call"
|
||||
- lambda: |-
|
||||
int brightness_percent = id(test_brightness);
|
||||
std::string computed = to_string(brightness_percent * 255 / 100);
|
||||
ESP_LOGI("test", "Lambda computed value: %s", computed.c_str());
|
||||
- homeassistant.action:
|
||||
action: light.turn_on
|
||||
data:
|
||||
entity_id: light.test_light
|
||||
# This creates a temporary string - the main test case
|
||||
brightness: !lambda 'return to_string(id(test_brightness) * 255 / 100);'
|
||||
data_template:
|
||||
color_name: !lambda 'return id(test_string);'
|
||||
variables:
|
||||
transition: !lambda 'return "2.5";'
|
||||
|
||||
# Test 3: Service call with empty string values
|
||||
- platform: template
|
||||
name: "Test Empty String Service"
|
||||
id: test_empty_string_service
|
||||
on_press:
|
||||
- logger.log: "Testing empty string values"
|
||||
- homeassistant.action:
|
||||
action: notify.test
|
||||
data:
|
||||
message: "Test message"
|
||||
title: ""
|
||||
data_template:
|
||||
target: !lambda 'return "";'
|
||||
variables:
|
||||
sound: !lambda 'return "";'
|
||||
|
||||
- logger.log: "Empty value for key: title"
|
||||
- logger.log: "Empty value for key: target"
|
||||
- logger.log: "Empty value for key: sound"
|
||||
|
||||
# Test 4: Service call with multiple data fields
|
||||
- platform: template
|
||||
name: "Test Multiple Fields Service"
|
||||
id: test_multiple_fields_service
|
||||
on_press:
|
||||
- logger.log: "Testing multiple data fields"
|
||||
- homeassistant.action:
|
||||
action: climate.set_temperature
|
||||
data:
|
||||
entity_id: climate.test_climate
|
||||
temperature: "22"
|
||||
hvac_mode: "heat"
|
||||
data_template:
|
||||
target_temp_high: !lambda 'return "24";'
|
||||
target_temp_low: !lambda 'return "20";'
|
||||
variables:
|
||||
preset_mode: !lambda 'return "comfort";'
|
||||
|
||||
# Test 5: Complex lambda with string operations
|
||||
- platform: template
|
||||
name: "Test Complex Lambda Service"
|
||||
id: test_complex_lambda_service
|
||||
on_press:
|
||||
- logger.log: "Testing complex lambda expressions"
|
||||
- homeassistant.action:
|
||||
action: script.test_script
|
||||
data:
|
||||
entity_id: !lambda |-
|
||||
std::string base = "light.";
|
||||
std::string room = "living_room";
|
||||
return base + room;
|
||||
brightness_pct: !lambda |-
|
||||
float sensor_val = id(test_sensor).state;
|
||||
int pct = (int)(sensor_val * 2.38); // 42 * 2.38 ≈ 100
|
||||
return to_string(pct);
|
||||
data_template:
|
||||
message: !lambda |-
|
||||
char buffer[50];
|
||||
snprintf(buffer, sizeof(buffer), "Sensor: %.1f, Time: %02d:%02d",
|
||||
id(test_sensor).state,
|
||||
id(homeassistant_time).now().hour,
|
||||
id(homeassistant_time).now().minute);
|
||||
return std::string(buffer);
|
||||
|
||||
# Test 6: Service with only empty strings to verify size calculation
|
||||
- platform: template
|
||||
name: "Test All Empty Service"
|
||||
id: test_all_empty_service
|
||||
on_press:
|
||||
- logger.log: "Testing all empty string values"
|
||||
- homeassistant.action:
|
||||
action: test.empty
|
||||
data:
|
||||
field1: ""
|
||||
field2: ""
|
||||
data_template:
|
||||
field3: !lambda 'return "";'
|
||||
variables:
|
||||
field4: !lambda 'return "";'
|
||||
- logger.log: "All empty service call completed"
|
||||
|
||||
# Test 7: Rapid successive service calls
|
||||
- platform: template
|
||||
name: "Test Rapid Service Calls"
|
||||
id: test_rapid_service_calls
|
||||
on_press:
|
||||
- logger.log: "Testing rapid service calls"
|
||||
- repeat:
|
||||
count: 5
|
||||
then:
|
||||
- homeassistant.action:
|
||||
action: counter.increment
|
||||
data:
|
||||
entity_id: counter.test_counter
|
||||
- delay: 10ms
|
||||
- logger.log: "Rapid service calls completed"
|
||||
|
||||
# Test 8: Log current HA states
|
||||
- platform: template
|
||||
name: "Test Read HA States"
|
||||
id: test_read_ha_states
|
||||
on_press:
|
||||
- logger.log: "Reading current HA states"
|
||||
- lambda: |-
|
||||
if (id(ha_temperature).has_state()) {
|
||||
ESP_LOGI("test", "Current HA Temperature: %.1f", id(ha_temperature).state);
|
||||
} else {
|
||||
ESP_LOGI("test", "HA Temperature has no state");
|
||||
}
|
||||
|
||||
if (id(ha_humidity).has_state()) {
|
||||
ESP_LOGI("test", "Current HA Humidity: %.1f", id(ha_humidity).state);
|
||||
} else {
|
||||
ESP_LOGI("test", "HA Humidity has no state");
|
||||
}
|
||||
|
||||
ESP_LOGI("test", "Current HA Motion: %s", id(ha_motion).state ? "ON" : "OFF");
|
||||
|
||||
if (id(ha_weather).has_state()) {
|
||||
ESP_LOGI("test", "Current HA Weather: %s", id(ha_weather).state.c_str());
|
||||
} else {
|
||||
ESP_LOGI("test", "HA Weather has no state");
|
||||
}
|
||||
|
||||
if (id(ha_empty_state).has_state()) {
|
||||
ESP_LOGI("test", "HA Empty State value: %s", id(ha_empty_state).state.c_str());
|
||||
} else {
|
||||
ESP_LOGI("test", "HA Empty State has no value (expected)");
|
||||
}
|
305
tests/integration/test_api_homeassistant.py
Normal file
305
tests/integration/test_api_homeassistant.py
Normal file
@ -0,0 +1,305 @@
|
||||
"""Integration test for Home Assistant API functionality.
|
||||
|
||||
Tests:
|
||||
- Home Assistant service calls with templated values (main bug fix)
|
||||
- Service calls with empty string values
|
||||
- Home Assistant state reading (sensors, binary sensors, text sensors)
|
||||
- Home Assistant number and switch component control
|
||||
- Complex lambda expressions and string handling
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from aioesphomeapi import HomeassistantServiceCall
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_homeassistant(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Comprehensive test for Home Assistant API functionality."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Create futures for patterns that capture values
|
||||
lambda_computed_future = loop.create_future()
|
||||
ha_temp_state_future = loop.create_future()
|
||||
ha_humidity_state_future = loop.create_future()
|
||||
ha_motion_state_future = loop.create_future()
|
||||
ha_weather_state_future = loop.create_future()
|
||||
|
||||
# State update futures
|
||||
temp_update_future = loop.create_future()
|
||||
humidity_update_future = loop.create_future()
|
||||
motion_update_future = loop.create_future()
|
||||
weather_update_future = loop.create_future()
|
||||
|
||||
# Number future
|
||||
ha_number_future = loop.create_future()
|
||||
|
||||
tests_complete_future = loop.create_future()
|
||||
|
||||
# Patterns to match in logs - only keeping patterns that capture values
|
||||
lambda_computed_pattern = re.compile(r"Lambda computed value: (\d+)")
|
||||
ha_temp_state_pattern = re.compile(r"Current HA Temperature: ([\d.]+)")
|
||||
ha_humidity_state_pattern = re.compile(r"Current HA Humidity: ([\d.]+)")
|
||||
ha_motion_state_pattern = re.compile(r"Current HA Motion: (ON|OFF)")
|
||||
ha_weather_state_pattern = re.compile(r"Current HA Weather: (\w+)")
|
||||
|
||||
# State update patterns
|
||||
temp_update_pattern = re.compile(r"HA Temperature state updated: ([\d.]+)")
|
||||
humidity_update_pattern = re.compile(r"HA Humidity state updated: ([\d.]+)")
|
||||
motion_update_pattern = re.compile(r"HA Motion state changed: (ON|OFF)")
|
||||
weather_update_pattern = re.compile(r"HA Weather condition updated: (\w+)")
|
||||
|
||||
# Number pattern
|
||||
ha_number_pattern = re.compile(r"Setting HA number to: ([\d.]+)")
|
||||
|
||||
tests_complete_pattern = re.compile(r"=== All tests completed ===")
|
||||
|
||||
# Track all log lines for debugging
|
||||
log_lines: list[str] = []
|
||||
|
||||
# Track HomeAssistant service calls
|
||||
ha_service_calls: list[HomeassistantServiceCall] = []
|
||||
|
||||
# Service call futures organized by service name
|
||||
service_call_futures = {
|
||||
"light.turn_off": loop.create_future(), # basic_service_call
|
||||
"light.turn_on": loop.create_future(), # templated_service_call
|
||||
"notify.test": loop.create_future(), # empty_string_service_call
|
||||
"climate.set_temperature": loop.create_future(), # multiple_fields_service_call
|
||||
"script.test_script": loop.create_future(), # complex_lambda_service_call
|
||||
"test.empty": loop.create_future(), # all_empty_service_call
|
||||
"input_number.set_value": loop.create_future(), # ha_number_service_call
|
||||
"switch.turn_on": loop.create_future(), # ha_switch_on_service_call
|
||||
"switch.turn_off": loop.create_future(), # ha_switch_off_service_call
|
||||
}
|
||||
|
||||
def on_service_call(service_call: HomeassistantServiceCall) -> None:
|
||||
"""Capture HomeAssistant service calls."""
|
||||
ha_service_calls.append(service_call)
|
||||
|
||||
# Check if this service call is one we're waiting for
|
||||
if service_call.service in service_call_futures:
|
||||
future = service_call_futures[service_call.service]
|
||||
if not future.done():
|
||||
future.set_result(service_call)
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for expected messages."""
|
||||
log_lines.append(line)
|
||||
|
||||
# Check for patterns that capture values
|
||||
if not lambda_computed_future.done():
|
||||
match = lambda_computed_pattern.search(line)
|
||||
if match:
|
||||
lambda_computed_future.set_result(match.group(1))
|
||||
elif not ha_temp_state_future.done() and ha_temp_state_pattern.search(line):
|
||||
ha_temp_state_future.set_result(line)
|
||||
elif not ha_humidity_state_future.done() and ha_humidity_state_pattern.search(
|
||||
line
|
||||
):
|
||||
ha_humidity_state_future.set_result(line)
|
||||
elif not ha_motion_state_future.done() and ha_motion_state_pattern.search(line):
|
||||
ha_motion_state_future.set_result(line)
|
||||
elif not ha_weather_state_future.done() and ha_weather_state_pattern.search(
|
||||
line
|
||||
):
|
||||
ha_weather_state_future.set_result(line)
|
||||
|
||||
# Check state update patterns
|
||||
elif not temp_update_future.done() and temp_update_pattern.search(line):
|
||||
temp_update_future.set_result(line)
|
||||
elif not humidity_update_future.done() and humidity_update_pattern.search(line):
|
||||
humidity_update_future.set_result(line)
|
||||
elif not motion_update_future.done() and motion_update_pattern.search(line):
|
||||
motion_update_future.set_result(line)
|
||||
elif not weather_update_future.done() and weather_update_pattern.search(line):
|
||||
weather_update_future.set_result(line)
|
||||
|
||||
# Check number pattern
|
||||
elif not ha_number_future.done() and ha_number_pattern.search(line):
|
||||
match = ha_number_pattern.search(line)
|
||||
if match:
|
||||
ha_number_future.set_result(match.group(1))
|
||||
|
||||
elif not tests_complete_future.done() and tests_complete_pattern.search(line):
|
||||
tests_complete_future.set_result(True)
|
||||
|
||||
# Run with log monitoring
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Verify device info
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "test-ha-api"
|
||||
|
||||
# Subscribe to HomeAssistant service calls
|
||||
client.subscribe_service_calls(on_service_call)
|
||||
|
||||
# Send some Home Assistant states for our sensors to read
|
||||
client.send_home_assistant_state("sensor.external_temperature", "", "22.5")
|
||||
client.send_home_assistant_state("sensor.external_humidity", "", "65.0")
|
||||
client.send_home_assistant_state("binary_sensor.external_motion", "", "ON")
|
||||
client.send_home_assistant_state("weather.home", "condition", "sunny")
|
||||
|
||||
# List entities and services
|
||||
_, services = await client.list_entities_services()
|
||||
|
||||
# Find the trigger service
|
||||
trigger_service = next(
|
||||
(s for s in services if s.name == "trigger_all_tests"), None
|
||||
)
|
||||
assert trigger_service is not None, "trigger_all_tests service not found"
|
||||
|
||||
# Execute all tests
|
||||
client.execute_service(trigger_service, {})
|
||||
|
||||
# Wait for all tests to complete with appropriate timeouts
|
||||
try:
|
||||
# Templated service test - the main bug fix
|
||||
computed_value = await asyncio.wait_for(lambda_computed_future, timeout=5.0)
|
||||
# Verify the computed value is reasonable (75 * 255 / 100 = 191.25 -> 191)
|
||||
assert computed_value in ["191", "192"], (
|
||||
f"Unexpected computed value: {computed_value}"
|
||||
)
|
||||
|
||||
# Check state reads - verify we received the mocked values
|
||||
temp_line = await asyncio.wait_for(ha_temp_state_future, timeout=5.0)
|
||||
assert "Current HA Temperature: 22.5" in temp_line
|
||||
|
||||
humidity_line = await asyncio.wait_for(
|
||||
ha_humidity_state_future, timeout=5.0
|
||||
)
|
||||
assert "Current HA Humidity: 65.0" in humidity_line
|
||||
|
||||
motion_line = await asyncio.wait_for(ha_motion_state_future, timeout=5.0)
|
||||
assert "Current HA Motion: ON" in motion_line
|
||||
|
||||
weather_line = await asyncio.wait_for(ha_weather_state_future, timeout=5.0)
|
||||
assert "Current HA Weather: sunny" in weather_line
|
||||
|
||||
# Number test
|
||||
number_value = await asyncio.wait_for(ha_number_future, timeout=5.0)
|
||||
assert number_value == "42.5", f"Unexpected number value: {number_value}"
|
||||
|
||||
# Wait for completion
|
||||
await asyncio.wait_for(tests_complete_future, timeout=5.0)
|
||||
|
||||
# Now verify the protobuf messages
|
||||
# 1. Basic service call
|
||||
basic_call = await asyncio.wait_for(
|
||||
service_call_futures["light.turn_off"], timeout=2.0
|
||||
)
|
||||
assert basic_call.service == "light.turn_off"
|
||||
assert "entity_id" in basic_call.data, (
|
||||
f"entity_id not found in data: {basic_call.data}"
|
||||
)
|
||||
assert basic_call.data["entity_id"] == "light.test_light", (
|
||||
f"Wrong entity_id: {basic_call.data['entity_id']}"
|
||||
)
|
||||
|
||||
# 2. Templated service call - verify the temporary string issue is fixed
|
||||
templated_call = await asyncio.wait_for(
|
||||
service_call_futures["light.turn_on"], timeout=2.0
|
||||
)
|
||||
assert templated_call.service == "light.turn_on"
|
||||
# Check the computed brightness value
|
||||
assert "brightness" in templated_call.data
|
||||
assert templated_call.data["brightness"] in ["191", "192"] # 75 * 255 / 100
|
||||
# Check data_template
|
||||
assert "color_name" in templated_call.data_template
|
||||
assert templated_call.data_template["color_name"] == "test_value"
|
||||
# Check variables
|
||||
assert "transition" in templated_call.variables
|
||||
assert templated_call.variables["transition"] == "2.5"
|
||||
|
||||
# 3. Empty string service call
|
||||
empty_call = await asyncio.wait_for(
|
||||
service_call_futures["notify.test"], timeout=2.0
|
||||
)
|
||||
assert empty_call.service == "notify.test"
|
||||
# Verify empty strings are properly handled
|
||||
assert "title" in empty_call.data and empty_call.data["title"] == ""
|
||||
assert (
|
||||
"target" in empty_call.data_template
|
||||
and empty_call.data_template["target"] == ""
|
||||
)
|
||||
assert (
|
||||
"sound" in empty_call.variables and empty_call.variables["sound"] == ""
|
||||
)
|
||||
|
||||
# 4. Multiple fields service call
|
||||
multi_call = await asyncio.wait_for(
|
||||
service_call_futures["climate.set_temperature"], timeout=2.0
|
||||
)
|
||||
assert multi_call.service == "climate.set_temperature"
|
||||
assert multi_call.data["temperature"] == "22"
|
||||
assert multi_call.data["hvac_mode"] == "heat"
|
||||
assert multi_call.data_template["target_temp_high"] == "24"
|
||||
assert multi_call.variables["preset_mode"] == "comfort"
|
||||
|
||||
# 5. Complex lambda service call
|
||||
complex_call = await asyncio.wait_for(
|
||||
service_call_futures["script.test_script"], timeout=2.0
|
||||
)
|
||||
assert complex_call.service == "script.test_script"
|
||||
assert complex_call.data["entity_id"] == "light.living_room"
|
||||
assert complex_call.data["brightness_pct"] == "99" # 42 * 2.38 ≈ 99
|
||||
# Check message includes sensor value
|
||||
assert "message" in complex_call.data_template
|
||||
assert "Sensor: 42.0" in complex_call.data_template["message"]
|
||||
|
||||
# 6. All empty service call
|
||||
all_empty_call = await asyncio.wait_for(
|
||||
service_call_futures["test.empty"], timeout=2.0
|
||||
)
|
||||
assert all_empty_call.service == "test.empty"
|
||||
# All fields should be empty strings
|
||||
assert all(v == "" for v in all_empty_call.data.values())
|
||||
assert all(v == "" for v in all_empty_call.data_template.values())
|
||||
assert all(v == "" for v in all_empty_call.variables.values())
|
||||
|
||||
# 7. HA Number service call
|
||||
number_call = await asyncio.wait_for(
|
||||
service_call_futures["input_number.set_value"], timeout=2.0
|
||||
)
|
||||
assert number_call.service == "input_number.set_value"
|
||||
assert number_call.data["entity_id"] == "input_number.test_number"
|
||||
# The value might be formatted with trailing zeros
|
||||
assert float(number_call.data["value"]) == 42.5
|
||||
|
||||
# 8. HA Switch service calls
|
||||
switch_on_call = await asyncio.wait_for(
|
||||
service_call_futures["switch.turn_on"], timeout=2.0
|
||||
)
|
||||
assert switch_on_call.service == "switch.turn_on"
|
||||
assert switch_on_call.data["entity_id"] == "switch.test_switch"
|
||||
|
||||
switch_off_call = await asyncio.wait_for(
|
||||
service_call_futures["switch.turn_off"], timeout=2.0
|
||||
)
|
||||
assert switch_off_call.service == "switch.turn_off"
|
||||
assert switch_off_call.data["entity_id"] == "switch.test_switch"
|
||||
|
||||
except TimeoutError as e:
|
||||
# Show recent log lines for debugging
|
||||
recent_logs = "\n".join(log_lines[-20:])
|
||||
service_calls_summary = "\n".join(
|
||||
f"- {call.service}" for call in ha_service_calls
|
||||
)
|
||||
pytest.fail(
|
||||
f"Test timed out waiting for expected log pattern or service call. Error: {e}\n\n"
|
||||
f"Recent log lines:\n{recent_logs}\n\n"
|
||||
f"Received service calls:\n{service_calls_summary}"
|
||||
)
|
@ -180,6 +180,69 @@ async def test_light_calls(
|
||||
state = await wait_for_state_change(rgb_light.key)
|
||||
assert state.state is False
|
||||
|
||||
# Test color mode combinations to verify get_suitable_color_modes optimization
|
||||
|
||||
# Test 22: White only mode
|
||||
client.light_command(key=rgbcw_light.key, state=True, white=0.5)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
|
||||
# Test 23: Color temperature only mode
|
||||
client.light_command(key=rgbcw_light.key, state=True, color_temperature=300)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.color_temperature == pytest.approx(300)
|
||||
|
||||
# Test 24: Cold/warm white only mode
|
||||
client.light_command(
|
||||
key=rgbcw_light.key, state=True, cold_white=0.6, warm_white=0.4
|
||||
)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.cold_white == pytest.approx(0.6)
|
||||
assert state.warm_white == pytest.approx(0.4)
|
||||
|
||||
# Test 25: RGB only mode
|
||||
client.light_command(key=rgb_light.key, state=True, rgb=(0.5, 0.5, 0.5))
|
||||
state = await wait_for_state_change(rgb_light.key)
|
||||
assert state.state is True
|
||||
|
||||
# Test 26: RGB + white combination
|
||||
client.light_command(
|
||||
key=rgbcw_light.key, state=True, rgb=(0.3, 0.3, 0.3), white=0.5
|
||||
)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
|
||||
# Test 27: RGB + color temperature combination
|
||||
client.light_command(
|
||||
key=rgbcw_light.key, state=True, rgb=(0.4, 0.4, 0.4), color_temperature=280
|
||||
)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
|
||||
# Test 28: RGB + cold/warm white combination
|
||||
client.light_command(
|
||||
key=rgbcw_light.key,
|
||||
state=True,
|
||||
rgb=(0.2, 0.2, 0.2),
|
||||
cold_white=0.5,
|
||||
warm_white=0.5,
|
||||
)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
|
||||
# Test 29: White + color temperature combination
|
||||
client.light_command(
|
||||
key=rgbcw_light.key, state=True, white=0.6, color_temperature=320
|
||||
)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
|
||||
# Test 30: No specific color parameters (tests default mode selection)
|
||||
client.light_command(key=rgbcw_light.key, state=True, brightness=0.75)
|
||||
state = await wait_for_state_change(rgbcw_light.key)
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.75)
|
||||
|
||||
# Final cleanup - turn all lights off
|
||||
for light in lights:
|
||||
client.light_command(
|
||||
|
Loading…
x
Reference in New Issue
Block a user