mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[homeassistant] Add text, select and button platforms (#19964)
This commit is contained in:
@@ -246,8 +246,11 @@ esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/button/* @jesserockz
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/select/* @jesserockz
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
esphome/components/homeassistant/text/* @jesserockz
|
||||
esphome/components/honeywell_hih_i2c/* @Benichou34
|
||||
esphome/components/honeywellabp/* @RubyBailey
|
||||
esphome/components/honeywellabp2_i2c/* @jpfaff
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
|
||||
homeassistant_ns,
|
||||
setup_home_assistant_entity,
|
||||
validate_entity_domain,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
DEPENDENCIES = ["api"]
|
||||
|
||||
SUPPORTED_DOMAINS = [
|
||||
"button",
|
||||
"input_button",
|
||||
]
|
||||
|
||||
HomeassistantButton = homeassistant_ns.class_(
|
||||
"HomeassistantButton", button.Button, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
button.button_schema(HomeassistantButton)
|
||||
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
validate_entity_domain("button", SUPPORTED_DOMAINS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
var = await button.new_button(config)
|
||||
await cg.register_component(var, config)
|
||||
setup_home_assistant_entity(var, config)
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "homeassistant_button.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
static const char *const TAG = "homeassistant.button";
|
||||
|
||||
void HomeassistantButton::dump_config() {
|
||||
LOG_BUTTON("", "Homeassistant Button", this);
|
||||
ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_);
|
||||
}
|
||||
|
||||
float HomeassistantButton::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
|
||||
|
||||
void HomeassistantButton::press_action() {
|
||||
if (!api::global_api_server->is_connected()) {
|
||||
ESP_LOGE(TAG, "No clients connected to API server");
|
||||
return;
|
||||
}
|
||||
|
||||
static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press");
|
||||
static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press");
|
||||
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
|
||||
static constexpr char INPUT_PREFIX[] = "input_";
|
||||
|
||||
api::HomeassistantActionRequest resp;
|
||||
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
|
||||
resp.service = SERVICE_INPUT_BUTTON;
|
||||
} else {
|
||||
resp.service = SERVICE_BUTTON;
|
||||
}
|
||||
|
||||
resp.data.init(1);
|
||||
auto &entity_id_kv = resp.data.emplace_back();
|
||||
entity_id_kv.key = ENTITY_ID_KEY;
|
||||
entity_id_kv.value = StringRef(this->entity_id_);
|
||||
|
||||
api::global_api_server->send_homeassistant_action(resp);
|
||||
}
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
class HomeassistantButton final : public button::Button, public Component {
|
||||
public:
|
||||
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
|
||||
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
protected:
|
||||
void press_action() override;
|
||||
|
||||
const char *entity_id_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,54 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
|
||||
homeassistant_ns,
|
||||
setup_home_assistant_entity,
|
||||
validate_entity_domain,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
DEPENDENCIES = ["api"]
|
||||
|
||||
CONF_MAX_OPTIONS = "max_options"
|
||||
CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size"
|
||||
|
||||
SUPPORTED_DOMAINS = [
|
||||
"input_select",
|
||||
"select",
|
||||
]
|
||||
|
||||
HomeassistantSelect = homeassistant_ns.class_(
|
||||
"HomeassistantSelect", select.Select, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
select.select_schema(HomeassistantSelect)
|
||||
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_MAX_OPTIONS, default=16): cv.int_range(min=1, max=255),
|
||||
cv.Optional(CONF_OPTIONS_BUFFER_SIZE, default=256): cv.int_range(
|
||||
min=2, max=4096
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
validate_entity_domain("select", SUPPORTED_DOMAINS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
# Options come from Home Assistant at runtime, into storage reserved during setup
|
||||
var = await select.new_select(
|
||||
config,
|
||||
config[CONF_MAX_OPTIONS],
|
||||
config[CONF_OPTIONS_BUFFER_SIZE],
|
||||
options=[],
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
setup_home_assistant_entity(var, config)
|
||||
@@ -0,0 +1,258 @@
|
||||
#include "homeassistant_select.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
static const char *const TAG = "homeassistant.select";
|
||||
|
||||
namespace {
|
||||
|
||||
/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early.
|
||||
template<typename C> bool emit_utf8(uint32_t code_point, C &on_char) {
|
||||
if (code_point == 0 || code_point > 0x10FFFF)
|
||||
return false;
|
||||
if (code_point < 0x80) {
|
||||
on_char(static_cast<char>(code_point));
|
||||
} else if (code_point < 0x800) {
|
||||
on_char(static_cast<char>(0xC0 | (code_point >> 6)));
|
||||
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
|
||||
} else if (code_point < 0x10000) {
|
||||
on_char(static_cast<char>(0xE0 | (code_point >> 12)));
|
||||
on_char(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
|
||||
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
|
||||
} else {
|
||||
on_char(static_cast<char>(0xF0 | (code_point >> 18)));
|
||||
on_char(static_cast<char>(0x80 | ((code_point >> 12) & 0x3F)));
|
||||
on_char(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
|
||||
on_char(static_cast<char>(0x80 | (code_point & 0x3F)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as
|
||||
* `['Low', "It's high", 'caf\xe9']`.
|
||||
*
|
||||
* Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false
|
||||
* on malformed input; the callbacks may already have run by then.
|
||||
*/
|
||||
template<typename C, typename O> bool parse_option_list(StringRef src, C &&on_char, O &&on_option) {
|
||||
const char *pos = src.c_str();
|
||||
const char *const end = pos + src.size();
|
||||
auto skip_spaces = [&]() {
|
||||
while (pos < end && *pos == ' ')
|
||||
pos++;
|
||||
};
|
||||
|
||||
skip_spaces();
|
||||
if (pos == end || *pos != '[')
|
||||
return false;
|
||||
pos++;
|
||||
skip_spaces();
|
||||
if (pos < end && *pos == ']') {
|
||||
pos++;
|
||||
} else {
|
||||
while (true) {
|
||||
skip_spaces();
|
||||
if (pos == end || (*pos != '\'' && *pos != '"'))
|
||||
return false;
|
||||
const char quote = *pos++;
|
||||
while (true) {
|
||||
if (pos == end)
|
||||
return false;
|
||||
const char c = *pos++;
|
||||
if (c == quote)
|
||||
break;
|
||||
if (c != '\\') {
|
||||
on_char(c);
|
||||
continue;
|
||||
}
|
||||
if (pos == end)
|
||||
return false;
|
||||
const char escape = *pos++;
|
||||
switch (escape) {
|
||||
case '\\':
|
||||
case '\'':
|
||||
case '"':
|
||||
on_char(escape);
|
||||
break;
|
||||
case 'n':
|
||||
on_char('\n');
|
||||
break;
|
||||
case 'r':
|
||||
on_char('\r');
|
||||
break;
|
||||
case 't':
|
||||
on_char('\t');
|
||||
break;
|
||||
case 'x':
|
||||
case 'u':
|
||||
case 'U': {
|
||||
const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8);
|
||||
if (static_cast<size_t>(end - pos) < digits)
|
||||
return false;
|
||||
auto code_point = parse_hex<uint32_t>(pos, digits);
|
||||
if (!code_point.has_value() || !emit_utf8(*code_point, on_char))
|
||||
return false;
|
||||
pos += digits;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
on_option();
|
||||
skip_spaces();
|
||||
if (pos == end)
|
||||
return false;
|
||||
const char separator = *pos++;
|
||||
if (separator == ']')
|
||||
break;
|
||||
if (separator != ',')
|
||||
return false;
|
||||
}
|
||||
}
|
||||
skip_spaces();
|
||||
return pos == end;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void HomeassistantSelect::setup() {
|
||||
this->options_buffer_ = std::make_unique<char[]>(this->options_buffer_size_);
|
||||
this->option_list_ = std::make_unique<const char *[]>(this->max_options_);
|
||||
|
||||
// Subscribe to the options first: Home Assistant answers subscriptions in order, so the options are
|
||||
// known by the time the first state arrives.
|
||||
api::global_api_server->subscribe_home_assistant_state(
|
||||
this->entity_id_, "options", [this](StringRef options) { this->options_changed_(options); });
|
||||
api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr,
|
||||
[this](StringRef state) { this->state_changed_(state); });
|
||||
}
|
||||
|
||||
void HomeassistantSelect::options_changed_(StringRef options) {
|
||||
// First pass: validate and measure the new options, and look for the active option among them, before
|
||||
// anything is overwritten. A list that does not fit leaves the current options in place.
|
||||
const char *active = this->has_state() ? this->option_at(this->active_index_) : nullptr;
|
||||
size_t count = 0;
|
||||
size_t bytes = 0;
|
||||
size_t match_len = 0;
|
||||
bool matching = active != nullptr;
|
||||
optional<size_t> new_active;
|
||||
bool valid = parse_option_list(
|
||||
options,
|
||||
[&](char c) {
|
||||
bytes++;
|
||||
if (matching && active[match_len] == c) {
|
||||
match_len++;
|
||||
} else {
|
||||
matching = false;
|
||||
}
|
||||
},
|
||||
[&]() {
|
||||
bytes++; // NUL terminator
|
||||
if (matching && active[match_len] == '\0' && !new_active.has_value())
|
||||
new_active = count;
|
||||
count++;
|
||||
match_len = 0;
|
||||
matching = active != nullptr;
|
||||
});
|
||||
if (!valid) {
|
||||
ESP_LOGW(TAG, "'%s': Can't parse options %s", this->entity_id_, options.c_str());
|
||||
return;
|
||||
}
|
||||
if (count > this->max_options_) {
|
||||
ESP_LOGE(TAG, "'%s': %zu options exceed max_options (%u)", this->entity_id_, count, this->max_options_);
|
||||
return;
|
||||
}
|
||||
if (bytes > this->options_buffer_size_) {
|
||||
ESP_LOGE(TAG, "'%s': Options need %zu bytes, more than options_buffer_size (%u)", this->entity_id_, bytes,
|
||||
this->options_buffer_size_);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second pass: the input is known to be valid and to fit, so write it out
|
||||
char *buffer = this->options_buffer_.get();
|
||||
size_t offset = 0;
|
||||
size_t option_start = 0;
|
||||
size_t index = 0;
|
||||
parse_option_list(
|
||||
options, [&](char c) { buffer[offset++] = c; },
|
||||
[&]() {
|
||||
buffer[offset++] = '\0';
|
||||
this->option_list_[index++] = buffer + option_start;
|
||||
option_start = offset;
|
||||
});
|
||||
// The traits only point at the list, which this select owns; set_options() would copy it to the heap
|
||||
this->traits.set_options_static(this->option_list_.get(), count);
|
||||
|
||||
// The active option may have moved to another index, or may be gone
|
||||
if (new_active.has_value()) {
|
||||
this->active_index_ = *new_active;
|
||||
} else {
|
||||
this->set_has_state(false);
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Got %zu options", this->entity_id_, count);
|
||||
}
|
||||
|
||||
void HomeassistantSelect::state_changed_(StringRef state) {
|
||||
auto index = this->index_of(state.c_str(), state.size());
|
||||
if (!index.has_value()) {
|
||||
ESP_LOGW(TAG, "'%s': State '%s' is not one of the options", this->entity_id_, state.c_str());
|
||||
return;
|
||||
}
|
||||
if (this->has_state() && this->active_index_ == *index)
|
||||
return;
|
||||
ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str());
|
||||
this->publish_state(*index);
|
||||
}
|
||||
|
||||
void HomeassistantSelect::dump_config() {
|
||||
LOG_SELECT("", "Homeassistant Select", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Entity ID: '%s'\n"
|
||||
" Max options: %u\n"
|
||||
" Options buffer size: %u",
|
||||
this->entity_id_, this->max_options_, this->options_buffer_size_);
|
||||
}
|
||||
|
||||
float HomeassistantSelect::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
|
||||
|
||||
void HomeassistantSelect::control(size_t index) {
|
||||
if (!api::global_api_server->is_connected()) {
|
||||
ESP_LOGE(TAG, "No clients connected to API server");
|
||||
return;
|
||||
}
|
||||
|
||||
static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option");
|
||||
static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option");
|
||||
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
|
||||
static constexpr auto OPTION_KEY = StringRef::from_lit("option");
|
||||
static constexpr char INPUT_PREFIX[] = "input_";
|
||||
|
||||
api::HomeassistantActionRequest resp;
|
||||
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
|
||||
resp.service = SERVICE_INPUT_SELECT;
|
||||
} else {
|
||||
resp.service = SERVICE_SELECT;
|
||||
}
|
||||
|
||||
resp.data.init(2);
|
||||
auto &entity_id = resp.data.emplace_back();
|
||||
entity_id.key = ENTITY_ID_KEY;
|
||||
entity_id.value = StringRef(this->entity_id_);
|
||||
|
||||
auto &option = resp.data.emplace_back();
|
||||
option.key = OPTION_KEY;
|
||||
option.value = StringRef(this->option_at(index));
|
||||
|
||||
api::global_api_server->send_homeassistant_action(resp);
|
||||
}
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
/** Mirrors a Home Assistant `select` or `input_select` entity.
|
||||
*
|
||||
* The options are read from the entity's `options` attribute at runtime. They are stored in a buffer of
|
||||
* `options_buffer_size` bytes and a list of at most `max_options` entries, both reserved in setup(), so a
|
||||
* change of options never allocates.
|
||||
*/
|
||||
class HomeassistantSelect final : public select::Select, public Component {
|
||||
public:
|
||||
HomeassistantSelect(uint8_t max_options, uint16_t options_buffer_size)
|
||||
: options_buffer_size_(options_buffer_size), max_options_(max_options) {}
|
||||
|
||||
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
protected:
|
||||
void options_changed_(StringRef options);
|
||||
void state_changed_(StringRef state);
|
||||
|
||||
void control(size_t index) override;
|
||||
|
||||
const char *entity_id_{nullptr};
|
||||
|
||||
private:
|
||||
// The traits point at option_list_, whose entries point into options_buffer_; both are sized once in
|
||||
// setup() and must match options_buffer_size_ and max_options_
|
||||
std::unique_ptr<char[]> options_buffer_;
|
||||
std::unique_ptr<const char *[]> option_list_;
|
||||
uint16_t options_buffer_size_;
|
||||
uint8_t max_options_;
|
||||
};
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,37 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
|
||||
homeassistant_ns,
|
||||
setup_home_assistant_entity,
|
||||
validate_entity_domain,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
DEPENDENCIES = ["api"]
|
||||
|
||||
SUPPORTED_DOMAINS = [
|
||||
"input_text",
|
||||
"text",
|
||||
]
|
||||
|
||||
HomeassistantText = homeassistant_ns.class_(
|
||||
"HomeassistantText", text.Text, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
text.text_schema(HomeassistantText, mode="TEXT")
|
||||
.extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
validate_entity_domain("text", SUPPORTED_DOMAINS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
|
||||
var = await text.new_text(config)
|
||||
await cg.register_component(var, config)
|
||||
setup_home_assistant_entity(var, config)
|
||||
@@ -0,0 +1,107 @@
|
||||
#include "homeassistant_text.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
static const char *const TAG = "homeassistant.text";
|
||||
|
||||
void HomeassistantText::state_changed_(StringRef state) {
|
||||
if (state == this->state) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str());
|
||||
this->publish_state(state.c_str(), state.size());
|
||||
}
|
||||
|
||||
void HomeassistantText::min_retrieved_(StringRef min) {
|
||||
auto min_value = parse_number<int>(min.c_str());
|
||||
if (!min_value.has_value()) {
|
||||
ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str());
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Min retrieved: %s", this->entity_id_, min.c_str());
|
||||
this->traits.set_min_length(min_value.value());
|
||||
}
|
||||
|
||||
void HomeassistantText::max_retrieved_(StringRef max) {
|
||||
auto max_value = parse_number<int>(max.c_str());
|
||||
if (!max_value.has_value()) {
|
||||
ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str());
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Max retrieved: %s", this->entity_id_, max.c_str());
|
||||
this->traits.set_max_length(max_value.value());
|
||||
}
|
||||
|
||||
void HomeassistantText::mode_retrieved_(StringRef mode) {
|
||||
if (mode == "text") {
|
||||
this->traits.set_mode(text::TEXT_MODE_TEXT);
|
||||
} else if (mode == "password") {
|
||||
this->traits.set_mode(text::TEXT_MODE_PASSWORD);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s': Unknown 'mode' value '%s'", this->entity_id_, mode.c_str());
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "'%s': Mode retrieved: %s", this->entity_id_, mode.c_str());
|
||||
}
|
||||
|
||||
void HomeassistantText::setup() {
|
||||
api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr,
|
||||
[this](StringRef state) { this->state_changed_(state); });
|
||||
|
||||
api::global_api_server->get_home_assistant_state(this->entity_id_, "min",
|
||||
[this](StringRef min) { this->min_retrieved_(min); });
|
||||
api::global_api_server->get_home_assistant_state(this->entity_id_, "max",
|
||||
[this](StringRef max) { this->max_retrieved_(max); });
|
||||
api::global_api_server->get_home_assistant_state(this->entity_id_, "mode",
|
||||
[this](StringRef mode) { this->mode_retrieved_(mode); });
|
||||
// The "pattern" attribute is not fetched: Home Assistant sends "None" when it is unset,
|
||||
// and a runtime pattern would need its own buffer.
|
||||
}
|
||||
|
||||
void HomeassistantText::dump_config() {
|
||||
LOG_TEXT("", "Homeassistant Text", this);
|
||||
ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_);
|
||||
}
|
||||
|
||||
float HomeassistantText::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
|
||||
|
||||
void HomeassistantText::control(const std::string &value) {
|
||||
if (!api::global_api_server->is_connected()) {
|
||||
ESP_LOGE(TAG, "No clients connected to API server");
|
||||
return;
|
||||
}
|
||||
|
||||
static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value");
|
||||
static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value");
|
||||
static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id");
|
||||
static constexpr auto VALUE_KEY = StringRef::from_lit("value");
|
||||
static constexpr char INPUT_PREFIX[] = "input_";
|
||||
|
||||
api::HomeassistantActionRequest resp;
|
||||
if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) {
|
||||
resp.service = SERVICE_INPUT_TEXT;
|
||||
} else {
|
||||
resp.service = SERVICE_TEXT;
|
||||
}
|
||||
|
||||
resp.data.init(2);
|
||||
auto &entity_id_kv = resp.data.emplace_back();
|
||||
entity_id_kv.key = ENTITY_ID_KEY;
|
||||
entity_id_kv.value = StringRef(this->entity_id_);
|
||||
|
||||
auto &value_kv = resp.data.emplace_back();
|
||||
value_kv.key = VALUE_KEY;
|
||||
value_kv.value = StringRef(value.data(), value.size());
|
||||
|
||||
api::global_api_server->send_homeassistant_action(resp);
|
||||
}
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/text/text.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome::homeassistant {
|
||||
|
||||
class HomeassistantText final : public text::Text, public Component {
|
||||
public:
|
||||
void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; }
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
protected:
|
||||
void state_changed_(StringRef state);
|
||||
void min_retrieved_(StringRef min);
|
||||
void max_retrieved_(StringRef max);
|
||||
void mode_retrieved_(StringRef mode);
|
||||
|
||||
void control(const std::string &value) override;
|
||||
|
||||
const char *entity_id_{nullptr};
|
||||
};
|
||||
|
||||
} // namespace esphome::homeassistant
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
button:
|
||||
- platform: homeassistant
|
||||
name: HA button
|
||||
entity_id: sensor.example
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
button:
|
||||
- platform: homeassistant
|
||||
id: ha_button
|
||||
name: HA button
|
||||
entity_id: button.example
|
||||
- platform: homeassistant
|
||||
id: ha_input_button
|
||||
name: HA input_button
|
||||
entity_id: input_button.example
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
select:
|
||||
- platform: homeassistant
|
||||
name: HA select
|
||||
entity_id: sensor.example
|
||||
@@ -0,0 +1,25 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
select:
|
||||
- platform: homeassistant
|
||||
id: ha_select
|
||||
name: HA select
|
||||
entity_id: select.example
|
||||
- platform: homeassistant
|
||||
id: ha_input_select
|
||||
name: HA input_select
|
||||
entity_id: input_select.example
|
||||
max_options: 4
|
||||
options_buffer_size: 64
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for the homeassistant button platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def test_button_valid_domains(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Both button and input_button entities are accepted and wired up."""
|
||||
main_cpp = generate_main(DIR / "button_valid.yaml")
|
||||
|
||||
assert "homeassistant::HomeassistantButton" in main_cpp
|
||||
assert 'ha_button->set_entity_id("button.example");' in main_cpp
|
||||
assert 'ha_input_button->set_entity_id("input_button.example");' in main_cpp
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines)
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
def test_button_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An entity from an unsupported domain is rejected."""
|
||||
CORE.config_path = DIR / "button_invalid.yaml"
|
||||
result = read_config({})
|
||||
|
||||
assert result is None
|
||||
assert (
|
||||
"Entity ID sensor.example is not supported by the button platform."
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for the homeassistant select platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def test_select_valid_domains(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Both select and input_select entities are accepted and wired up."""
|
||||
main_cpp = generate_main(DIR / "select_valid.yaml")
|
||||
|
||||
# Default and configured option storage are passed to the constructor
|
||||
assert "new(ha_select) homeassistant::HomeassistantSelect(16, 256);" in main_cpp
|
||||
assert "new(ha_input_select) homeassistant::HomeassistantSelect(4, 64);" in main_cpp
|
||||
assert 'ha_select->set_entity_id("select.example");' in main_cpp
|
||||
assert 'ha_input_select->set_entity_id("input_select.example");' in main_cpp
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines)
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
def test_select_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An entity from an unsupported domain is rejected."""
|
||||
CORE.config_path = DIR / "select_invalid.yaml"
|
||||
result = read_config({})
|
||||
|
||||
assert result is None
|
||||
assert (
|
||||
"Entity ID sensor.example is not supported by the select platform."
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for the homeassistant text platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def test_text_valid_domains(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Both text and input_text entities are accepted and wired up."""
|
||||
main_cpp = generate_main(DIR / "text_valid.yaml")
|
||||
|
||||
assert "homeassistant::HomeassistantText" in main_cpp
|
||||
assert 'ha_text->set_entity_id("text.example");' in main_cpp
|
||||
assert 'ha_input_text->set_entity_id("input_text.example");' in main_cpp
|
||||
assert "ha_text->traits.set_mode(text::TEXT_MODE_TEXT);" in main_cpp
|
||||
assert "ha_text->traits.set_min_length(0);" in main_cpp
|
||||
assert "ha_text->traits.set_max_length(255);" in main_cpp
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines)
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
def test_text_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An entity from an unsupported domain is rejected."""
|
||||
CORE.config_path = DIR / "text_invalid.yaml"
|
||||
result = read_config({})
|
||||
|
||||
assert result is None
|
||||
assert (
|
||||
"Entity ID sensor.example is not supported by the text platform."
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
text:
|
||||
- platform: homeassistant
|
||||
name: HA text
|
||||
entity_id: sensor.example
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
text:
|
||||
- platform: homeassistant
|
||||
id: ha_text
|
||||
name: HA text
|
||||
entity_id: text.example
|
||||
- platform: homeassistant
|
||||
id: ha_input_text
|
||||
name: HA input_text
|
||||
entity_id: input_text.example
|
||||
@@ -26,10 +26,6 @@ esphome:
|
||||
variables:
|
||||
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
switch:
|
||||
@@ -75,6 +71,33 @@ number:
|
||||
entity_id: input_number.hello_world
|
||||
id: ha_hello_world_input_number
|
||||
|
||||
select:
|
||||
- platform: homeassistant
|
||||
entity_id: select.hello_world
|
||||
id: ha_hello_world_select
|
||||
- platform: homeassistant
|
||||
entity_id: input_select.hello_world
|
||||
id: ha_hello_world_input_select
|
||||
max_options: 8
|
||||
options_buffer_size: 128
|
||||
|
||||
text:
|
||||
- platform: homeassistant
|
||||
entity_id: text.hello_world
|
||||
id: ha_hello_world_text_input
|
||||
- platform: homeassistant
|
||||
entity_id: input_text.hello_world
|
||||
id: ha_hello_world_input_text
|
||||
mode: password
|
||||
|
||||
button:
|
||||
- platform: homeassistant
|
||||
entity_id: button.hello_world
|
||||
id: ha_hello_world_button
|
||||
- platform: homeassistant
|
||||
entity_id: input_button.hello_world
|
||||
id: ha_hello_world_input_button
|
||||
|
||||
sensor:
|
||||
- platform: homeassistant
|
||||
entity_id: sensor.hello_world
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
esphome:
|
||||
name: ha-entities
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
- action: dump_select
|
||||
variables:
|
||||
tag: string
|
||||
then:
|
||||
- logger.log:
|
||||
format: "dump %s: has_state=%d index=%d option='%s'"
|
||||
args:
|
||||
- tag.c_str()
|
||||
- id(ha_select).has_state()
|
||||
- (int) id(ha_select).active_index().value_or(-1)
|
||||
- id(ha_select).current_option().c_str()
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
select:
|
||||
- platform: homeassistant
|
||||
name: HA Select
|
||||
id: ha_select
|
||||
entity_id: select.mode
|
||||
internal: false
|
||||
max_options: 4
|
||||
options_buffer_size: 40
|
||||
on_value:
|
||||
- logger.log:
|
||||
format: "select value '%s' at %u"
|
||||
args: [x.c_str(), (unsigned) i]
|
||||
- platform: homeassistant
|
||||
name: HA Input Select
|
||||
entity_id: input_select.colour
|
||||
internal: false
|
||||
|
||||
text:
|
||||
- platform: homeassistant
|
||||
name: HA Text
|
||||
entity_id: text.note
|
||||
internal: false
|
||||
on_value:
|
||||
- logger.log:
|
||||
format: "text value '%s'"
|
||||
args: [x.c_str()]
|
||||
- platform: homeassistant
|
||||
name: HA Input Text
|
||||
entity_id: input_text.name
|
||||
internal: false
|
||||
|
||||
button:
|
||||
- platform: homeassistant
|
||||
name: HA Button
|
||||
entity_id: button.doorbell
|
||||
internal: false
|
||||
- platform: homeassistant
|
||||
name: HA Input Button
|
||||
entity_id: input_button.reset
|
||||
internal: false
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Test the homeassistant select, text and button platforms against a fake Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from aioesphomeapi import APIClient, HomeassistantServiceCall, UserService
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
SUBSCRIPTIONS = {
|
||||
("select.mode", "options"),
|
||||
("select.mode", None),
|
||||
("input_select.colour", "options"),
|
||||
("input_select.colour", None),
|
||||
("text.note", None),
|
||||
("text.note", "min"),
|
||||
("text.note", "max"),
|
||||
("text.note", "mode"),
|
||||
("input_text.name", None),
|
||||
("input_text.name", "min"),
|
||||
("input_text.name", "max"),
|
||||
("input_text.name", "mode"),
|
||||
}
|
||||
|
||||
# Each is rejected by the options parser, leaving the previous options in place
|
||||
MALFORMED_OPTIONS = (
|
||||
"None",
|
||||
"",
|
||||
"[",
|
||||
"['a'",
|
||||
"['a',",
|
||||
"['a' 'b']",
|
||||
"['a'] x",
|
||||
"[1]",
|
||||
"['a\\q']",
|
||||
"['a\\",
|
||||
"['\\x4']",
|
||||
"['\\x0']",
|
||||
"['\\x00']",
|
||||
"['\\U00110000']",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_homeassistant_entities(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Options, state and actions of the homeassistant select, text and button platforms."""
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
async def next_call() -> HomeassistantServiceCall:
|
||||
return await asyncio.wait_for(calls.get(), timeout=5.0)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, services = await client.list_entities_services()
|
||||
keys = {entity.name: entity.key for entity in entities}
|
||||
dump_select = next(s for s in services if s.name == "dump_select")
|
||||
client.subscribe_service_calls(calls.put_nowait)
|
||||
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}")
|
||||
|
||||
await _check_select(client, waiter, keys, dump_select, next_call)
|
||||
await _check_text(client, waiter, keys, next_call)
|
||||
|
||||
client.button_command(keys["HA Button"])
|
||||
call = await next_call()
|
||||
assert call.service == "button.press"
|
||||
assert call.data == {"entity_id": "button.doorbell"}
|
||||
client.button_command(keys["HA Input Button"])
|
||||
call = await next_call()
|
||||
assert call.service == "input_button.press"
|
||||
assert call.data == {"entity_id": "input_button.reset"}
|
||||
|
||||
assert calls.empty()
|
||||
|
||||
|
||||
async def _check_select(
|
||||
client: APIClient,
|
||||
waiter: LineWaiter,
|
||||
keys: dict[str, int],
|
||||
dump_select: UserService,
|
||||
next_call: Callable[[], Awaitable[HomeassistantServiceCall]],
|
||||
) -> None:
|
||||
async def dump(tag: str) -> str:
|
||||
await client.execute_service(dump_select, {"tag": tag})
|
||||
return await waiter.wait_for(f"dump {tag}:", timeout=5.0)
|
||||
|
||||
# Built with str() exactly as Home Assistant does, so every escape it emits is covered
|
||||
options = ["Low", "It's", "café\t\x7f\\", "\u200b\U000e0001\r\n'\""]
|
||||
client.send_home_assistant_state("select.mode", "options", str(options))
|
||||
await waiter.wait_for("'select.mode': Got 4 options", timeout=5.0)
|
||||
|
||||
client.send_home_assistant_state("select.mode", "", "It's")
|
||||
await waiter.wait_for("select value 'It's' at 1", timeout=5.0)
|
||||
assert "has_state=1 index=1 option='It's'" in await dump("first")
|
||||
|
||||
# Decoded options round trip through select.select_option unchanged
|
||||
for option in options[2:]:
|
||||
client.select_command(keys["HA Select"], option)
|
||||
call = await next_call()
|
||||
assert call.service == "select.select_option"
|
||||
assert call.data == {"entity_id": "select.mode", "option": option}
|
||||
|
||||
# The active option keeps its state when it moves; double quoted options parse too
|
||||
client.send_home_assistant_state("select.mode", "options", "[\"It's\", 'L\\\"ow']")
|
||||
await waiter.wait_for("'select.mode': Got 2 options", timeout=5.0)
|
||||
assert "has_state=1 index=0 option='It's'" in await dump("moved")
|
||||
client.select_command(keys["HA Select"], 'L"ow')
|
||||
assert (await next_call()).data["option"] == 'L"ow'
|
||||
|
||||
# Dropping the active option clears the state
|
||||
client.send_home_assistant_state(
|
||||
"select.mode", "options", "[ 'Low' , 'Max' , 'High' ]"
|
||||
)
|
||||
await waiter.wait_for("'select.mode': Got 3 options", timeout=5.0)
|
||||
assert "has_state=0" in await dump("dropped")
|
||||
|
||||
client.send_home_assistant_state("select.mode", "", "unavailable")
|
||||
await waiter.wait_for("State 'unavailable' is not one of the options", timeout=5.0)
|
||||
|
||||
# Rejected lists keep the previous options
|
||||
client.send_home_assistant_state("select.mode", "options", str(list("abcde")))
|
||||
await waiter.wait_for("5 options exceed max_options (4)", timeout=5.0)
|
||||
client.send_home_assistant_state("select.mode", "options", str(["x" * 50]))
|
||||
await waiter.wait_for(
|
||||
"Options need 51 bytes, more than options_buffer_size (40)", timeout=5.0
|
||||
)
|
||||
for malformed in MALFORMED_OPTIONS:
|
||||
client.send_home_assistant_state("select.mode", "options", malformed)
|
||||
client.send_home_assistant_state("select.mode", "", "Max")
|
||||
await waiter.wait_for("select value 'Max' at 1", timeout=5.0)
|
||||
assert sum("Can't parse options" in line for line in waiter.lines) == len(
|
||||
MALFORMED_OPTIONS
|
||||
)
|
||||
|
||||
# Repeating the active state does not publish again
|
||||
client.send_home_assistant_state("select.mode", "", "Max")
|
||||
client.send_home_assistant_state("select.mode", "options", "[]")
|
||||
await waiter.wait_for("'select.mode': Got 0 options", timeout=5.0)
|
||||
assert sum("select value 'Max'" in line for line in waiter.lines) == 1
|
||||
assert "has_state=0 index=-1" in await dump("empty")
|
||||
|
||||
client.send_home_assistant_state(
|
||||
"input_select.colour", "options", "['Red', 'Green']"
|
||||
)
|
||||
await waiter.wait_for("'input_select.colour': Got 2 options", timeout=5.0)
|
||||
client.select_command(keys["HA Input Select"], "Green")
|
||||
call = await next_call()
|
||||
assert call.service == "input_select.select_option"
|
||||
assert call.data == {"entity_id": "input_select.colour", "option": "Green"}
|
||||
|
||||
|
||||
async def _check_text(
|
||||
client: APIClient,
|
||||
waiter: LineWaiter,
|
||||
keys: dict[str, int],
|
||||
next_call: Callable[[], Awaitable[HomeassistantServiceCall]],
|
||||
) -> None:
|
||||
client.send_home_assistant_state("text.note", "min", "2")
|
||||
client.send_home_assistant_state("text.note", "max", "10")
|
||||
client.send_home_assistant_state("text.note", "mode", "password")
|
||||
await waiter.wait_for("'text.note': Mode retrieved: password", timeout=5.0)
|
||||
|
||||
client.send_home_assistant_state("input_text.name", "min", "abc")
|
||||
client.send_home_assistant_state("input_text.name", "max", "x")
|
||||
client.send_home_assistant_state("input_text.name", "mode", "fancy")
|
||||
await waiter.wait_for(
|
||||
"'input_text.name': Unknown 'mode' value 'fancy'", timeout=5.0
|
||||
)
|
||||
await waiter.wait_for("Can't convert 'min' value 'abc'", timeout=5.0)
|
||||
await waiter.wait_for("Can't convert 'max' value 'x'", timeout=5.0)
|
||||
client.send_home_assistant_state("input_text.name", "mode", "text")
|
||||
await waiter.wait_for("'input_text.name': Mode retrieved: text", timeout=5.0)
|
||||
|
||||
client.send_home_assistant_state("text.note", "", "hello")
|
||||
client.send_home_assistant_state("text.note", "", "hello")
|
||||
client.send_home_assistant_state("text.note", "", "there")
|
||||
await waiter.wait_for("text value 'there'", timeout=5.0)
|
||||
assert sum("text value 'hello'" in line for line in waiter.lines) == 1
|
||||
|
||||
# The limits from Home Assistant apply: too short and too long are refused
|
||||
client.text_command(keys["HA Text"], "a")
|
||||
client.text_command(keys["HA Text"], "abcdefghijk")
|
||||
client.text_command(keys["HA Text"], "fine")
|
||||
call = await next_call()
|
||||
assert call.service == "text.set_value"
|
||||
assert call.data == {"entity_id": "text.note", "value": "fine"}
|
||||
|
||||
client.text_command(keys["HA Input Text"], "Bob")
|
||||
call = await next_call()
|
||||
assert call.service == "input_text.set_value"
|
||||
assert call.data == {"entity_id": "input_text.name", "value": "Bob"}
|
||||
Reference in New Issue
Block a user