mirror of
https://github.com/esphome/esphome.git
synced 2026-10-23 06:15:58 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4922b2b25f | ||
|
|
c43f039631 | ||
|
|
51d2df33a3 | ||
|
|
8d1476d585 | ||
|
|
25b8c93ad6 | ||
|
|
5b1642b721 | ||
|
|
b62e12007f | ||
|
|
68d24cb215 | ||
|
|
dcfad5c439 | ||
|
|
e08cbc5661 | ||
|
|
2b183be544 |
@@ -1,5 +1,6 @@
|
||||
#include "download_buffer.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::online_image {
|
||||
@@ -34,6 +35,46 @@ size_t DownloadBuffer::read(size_t len) {
|
||||
return this->unread_;
|
||||
}
|
||||
|
||||
void DownloadSource::compact() {
|
||||
if (this->keep_read_ || this->pos_ == this->start_)
|
||||
return;
|
||||
this->buffer_.read(this->pos_ - this->start_);
|
||||
this->start_ = this->pos_;
|
||||
}
|
||||
|
||||
int32_t DownloadSource::read(uint8_t *buf, uint32_t len) {
|
||||
const size_t offset = this->pos_ - this->start_;
|
||||
const size_t n = std::min<size_t>(len, this->buffer_.unread() - offset);
|
||||
memcpy(buf, this->buffer_.data(offset), n);
|
||||
this->pos_ += n;
|
||||
return static_cast<int32_t>(n);
|
||||
}
|
||||
|
||||
bool DownloadSource::seek(uint32_t position) {
|
||||
// Only what is still in the buffer can be read again; a decoder never skips ahead of the download
|
||||
if (position < this->start_ || position > this->start_ + this->buffer_.unread())
|
||||
return false;
|
||||
this->pos_ = position;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DownloadBuffer::grow(size_t size) {
|
||||
if (this->size_ >= size)
|
||||
return true;
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
uint8_t *buffer = allocator.allocate(size);
|
||||
if (buffer == nullptr) {
|
||||
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", size,
|
||||
allocator.get_max_free_block_size());
|
||||
return false;
|
||||
}
|
||||
memcpy(buffer, this->buffer_, this->unread_);
|
||||
allocator.deallocate(this->buffer_, this->size_);
|
||||
this->buffer_ = buffer;
|
||||
this->size_ = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t DownloadBuffer::resize(size_t size) {
|
||||
if (this->size_ >= size) {
|
||||
// Avoid useless reallocations; if the buffer is big enough, don't reallocate.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -35,6 +36,8 @@ class DownloadBuffer {
|
||||
|
||||
void reset() { this->unread_ = 0; }
|
||||
size_t resize(size_t size);
|
||||
/// Make the buffer at least size bytes, keeping the unread data. Returns false if the allocation failed.
|
||||
bool grow(size_t size);
|
||||
|
||||
protected:
|
||||
uint8_t *buffer_;
|
||||
@@ -43,4 +46,35 @@ class DownloadBuffer {
|
||||
size_t unread_;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Lets a decoder read a download as it arrives, from the download buffer.
|
||||
*
|
||||
* The decoder reads at its own position in the buffer. Bytes it has read stay there until compact() drops
|
||||
* them, and compact() keeps everything while set_keep_read(true) is in effect, so the decoder can seek back
|
||||
* while it reads the headers.
|
||||
*/
|
||||
class DownloadSource : public runtime_image::DecodeSource {
|
||||
public:
|
||||
explicit DownloadSource(DownloadBuffer &buffer) : buffer_(buffer) {}
|
||||
|
||||
/// Start again at the beginning of a new download, keeping what is read
|
||||
void reset() {
|
||||
this->start_ = 0;
|
||||
this->pos_ = 0;
|
||||
this->keep_read_ = true;
|
||||
}
|
||||
void set_keep_read(bool keep) { this->keep_read_ = keep; }
|
||||
/// Drop the bytes the decoder has read, to make room for more of the download
|
||||
void compact();
|
||||
|
||||
int32_t read(uint8_t *buf, uint32_t len) override;
|
||||
bool seek(uint32_t position) override;
|
||||
|
||||
protected:
|
||||
DownloadBuffer &buffer_;
|
||||
size_t start_{0}; // Position in the download of the first byte in the buffer
|
||||
size_t pos_{0}; // Position in the download the decoder reads next
|
||||
bool keep_read_{true};
|
||||
};
|
||||
|
||||
} // namespace esphome::online_image
|
||||
|
||||
@@ -22,6 +22,8 @@ DEPENDENCIES = ["http_request"]
|
||||
CODEOWNERS = ["@guillempages", "@clydebarrow"]
|
||||
|
||||
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
|
||||
CONF_ON_DOWNLOAD_STARTED = "on_download_started"
|
||||
CONF_ON_DOWNLOAD_PROGRESS = "on_download_progress"
|
||||
CONF_UPDATE = "update"
|
||||
|
||||
online_image_ns = cg.esphome_ns.namespace("online_image")
|
||||
@@ -43,7 +45,9 @@ ONLINE_IMAGE_SCHEMA = (
|
||||
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
|
||||
cv.Schema({cv.string: cv.templatable(cv.string)})
|
||||
),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_STARTED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_DOWNLOAD_PROGRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
@@ -96,9 +100,17 @@ automation.register_apply_action(
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_STARTED, "add_on_download_started_callback"
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_DOWNLOAD_PROGRESS,
|
||||
"add_on_progress_callback",
|
||||
[(float, "progress"), (cg.size_t, "bytes_read")],
|
||||
),
|
||||
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "online_image");
|
||||
@@ -15,6 +16,11 @@ static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
|
||||
|
||||
namespace esphome::online_image {
|
||||
|
||||
// Time one loop pass may spend reading, and decoding a JPEG as it downloads
|
||||
static constexpr uint32_t PASS_BUDGET_MS = 20;
|
||||
// Shortest time between two on_download_progress calls
|
||||
static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
|
||||
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
|
||||
uint32_t buffer_size, bool is_big_endian)
|
||||
@@ -140,8 +146,12 @@ void OnlineImage::update() {
|
||||
return;
|
||||
}
|
||||
|
||||
// JPEG requires the complete image in the download buffer before decoding
|
||||
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
|
||||
// A JPEG of known size is decoded as it arrives, so the download buffer only has to hold its headers.
|
||||
// Otherwise the decoder needs the complete image in the buffer.
|
||||
this->streaming_ = total_size > 0 && total_size <= INT32_MAX && this->decoder_supports_source();
|
||||
this->stream_started_ = false;
|
||||
this->source_.reset();
|
||||
if (!this->streaming_ && format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
|
||||
this->download_buffer_.resize(total_size) == 0) {
|
||||
// resize() logged the failed allocation; the next update() allocates the configured buffer again
|
||||
this->end_connection_();
|
||||
@@ -152,6 +162,8 @@ void OnlineImage::update() {
|
||||
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
|
||||
this->start_time_ = millis();
|
||||
this->last_data_time_ = this->start_time_;
|
||||
this->download_started_callback_.call();
|
||||
this->report_progress_(true);
|
||||
this->enable_loop();
|
||||
}
|
||||
|
||||
@@ -169,6 +181,11 @@ void OnlineImage::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->streaming_) {
|
||||
this->loop_stream_();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if download is complete — use decoder's format-specific completion check
|
||||
// to handle both known content-length and chunked transfer encoding
|
||||
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
|
||||
@@ -179,15 +196,22 @@ void OnlineImage::loop() {
|
||||
}
|
||||
|
||||
// Download and decode more data
|
||||
size_t available = this->download_buffer_.free_capacity();
|
||||
if (available > 0) {
|
||||
// Download in chunks to avoid blocking
|
||||
available = std::min(available, this->download_buffer_initial_size_);
|
||||
auto len = this->downloader_->read(this->download_buffer_.append(), available);
|
||||
|
||||
if (len > 0) {
|
||||
this->last_data_time_ = App.get_loop_component_start_time();
|
||||
if (this->download_buffer_.free_capacity() > 0) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
size_t received = 0;
|
||||
int len;
|
||||
do {
|
||||
len = this->downloader_->read(this->download_buffer_.append(),
|
||||
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
|
||||
if (len <= 0)
|
||||
break;
|
||||
this->download_buffer_.write(len);
|
||||
received += len;
|
||||
} while (this->download_buffer_.free_capacity() != 0 && millis() - now < PASS_BUDGET_MS);
|
||||
|
||||
if (received > 0) {
|
||||
this->last_data_time_ = now;
|
||||
this->report_progress_(false);
|
||||
|
||||
// Feed data to decoder
|
||||
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
|
||||
@@ -255,6 +279,114 @@ void OnlineImage::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
void OnlineImage::loop_stream_() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const size_t total = this->downloader_->content_length;
|
||||
if (this->stream_started_)
|
||||
this->source_.compact();
|
||||
|
||||
if (this->downloader_->get_bytes_read() < total) {
|
||||
if (this->download_buffer_.free_capacity() == 0) {
|
||||
// compact() already ran, so a full buffer means the decoder read nothing in the last pass
|
||||
if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
|
||||
ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
|
||||
this->download_buffer_.size());
|
||||
this->fail_download_();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
int len;
|
||||
do {
|
||||
len = this->downloader_->read(this->download_buffer_.append(),
|
||||
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
|
||||
if (len <= 0)
|
||||
break;
|
||||
this->last_data_time_ = now;
|
||||
this->download_buffer_.write(len);
|
||||
this->report_progress_(false);
|
||||
} while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
|
||||
millis() - now < PASS_BUDGET_MS);
|
||||
if (len < 0) {
|
||||
ESP_LOGE(TAG, "Error downloading image: %d", len);
|
||||
this->fail_download_();
|
||||
return;
|
||||
} else if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
|
||||
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
|
||||
this->fail_download_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const bool arrived = this->downloader_->get_bytes_read() >= total;
|
||||
|
||||
if (!this->stream_started_) {
|
||||
// The decoder reads the headers when it opens the image, so wait until they are in the buffer
|
||||
if (!arrived && this->download_buffer_.free_capacity() != 0)
|
||||
return;
|
||||
const int err = this->begin_source(&this->source_);
|
||||
if (err != 0) {
|
||||
// Headers cut short by a full buffer fail to parse, or make the decoder seek past the buffer. Such an image
|
||||
// still decodes once it is all in memory, as it did before JPEG images were decoded as they download.
|
||||
if (!arrived && (err == runtime_image::DECODE_ERROR_INVALID_TYPE || err == runtime_image::DECODE_ERROR_READ)) {
|
||||
ESP_LOGW(TAG,
|
||||
"The image headers do not fit the %zu byte download buffer, so the whole image is downloaded "
|
||||
"before it is decoded; increase buffer_size to decode it as it downloads",
|
||||
this->download_buffer_.size());
|
||||
// grow() logs a failed allocation
|
||||
if (this->download_buffer_.grow(total)) {
|
||||
this->streaming_ = false;
|
||||
} else {
|
||||
this->fail_download_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(err));
|
||||
this->fail_download_();
|
||||
return;
|
||||
}
|
||||
this->source_.set_keep_read(false);
|
||||
this->stream_started_ = true;
|
||||
// Opening the image and allocating its buffer is enough for one pass
|
||||
return;
|
||||
}
|
||||
|
||||
do {
|
||||
const int result = this->decode_step();
|
||||
if (result == runtime_image::DECODE_STEP_MORE)
|
||||
continue;
|
||||
if (result == runtime_image::DECODE_STEP_DONE) {
|
||||
this->finish_download_();
|
||||
} else if (result == runtime_image::DECODE_STEP_NEED_DATA) {
|
||||
if (!arrived)
|
||||
return;
|
||||
// JPEGDEC only waits for bytes short of the size it was given, so this guards against a hang
|
||||
ESP_LOGE(TAG, "Download ended before the image was complete");
|
||||
this->fail_download_();
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(result));
|
||||
this->fail_download_();
|
||||
}
|
||||
return;
|
||||
} while (millis() - now < PASS_BUDGET_MS);
|
||||
}
|
||||
|
||||
void OnlineImage::report_progress_(bool force) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const size_t bytes = this->downloader_->get_bytes_read();
|
||||
const size_t total = this->downloader_->content_length;
|
||||
// The last bytes are always reported, so a progress bar reaches 100%
|
||||
if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
|
||||
return;
|
||||
this->last_progress_time_ = now;
|
||||
const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
|
||||
this->download_progress_callback_.call(progress, bytes);
|
||||
}
|
||||
|
||||
void OnlineImage::fail_download_() {
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
}
|
||||
|
||||
void OnlineImage::finish_download_() {
|
||||
// Must be read before end_decode() ends the decoding session
|
||||
bool decoder_finished = this->is_decode_finished();
|
||||
@@ -288,6 +420,8 @@ void OnlineImage::end_connection_() {
|
||||
this->downloader_ = nullptr;
|
||||
}
|
||||
this->download_buffer_.reset();
|
||||
this->streaming_ = false;
|
||||
this->stream_started_ = false;
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ using t_http_codes = enum {
|
||||
HTTP_CODE_NOT_FOUND = 404,
|
||||
};
|
||||
|
||||
/// Largest download read(). A read() blocks until it has every byte it asked for, so this bounds how long one loop
|
||||
/// pass waits on a slow link.
|
||||
static constexpr size_t READ_CHUNK = 1460;
|
||||
|
||||
/**
|
||||
* @brief Download an image from a given URL, and decode it using the specified decoder.
|
||||
* The image will then be stored in a buffer, so that it can be re-displayed without the
|
||||
@@ -70,9 +74,17 @@ class OnlineImage final : public PollingComponent,
|
||||
*/
|
||||
void release();
|
||||
|
||||
/// Called when the server has accepted the request and the image starts to download
|
||||
template<typename F> void add_on_download_started_callback(F &&callback) {
|
||||
this->download_started_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_finished_callback(F &&callback) {
|
||||
this->download_finished_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// Called with the percentage downloaded (NAN when the server did not send the size) and the bytes read so far
|
||||
template<typename F> void add_on_progress_callback(F &&callback) {
|
||||
this->download_progress_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_error_callback(F &&callback) {
|
||||
this->download_error_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
@@ -81,12 +93,20 @@ class OnlineImage final : public PollingComponent,
|
||||
bool validate_url_(const std::string &url);
|
||||
void end_connection_();
|
||||
void finish_download_();
|
||||
/// loop() for a JPEG that is decoded as it downloads
|
||||
void loop_stream_();
|
||||
void fail_download_();
|
||||
/// Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set
|
||||
void report_progress_(bool force);
|
||||
|
||||
CallbackManager<void(bool)> download_finished_callback_{};
|
||||
CallbackManager<void()> download_error_callback_{};
|
||||
LazyCallbackManager<void()> download_started_callback_{};
|
||||
LazyCallbackManager<void(float, size_t)> download_progress_callback_{};
|
||||
|
||||
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
|
||||
DownloadBuffer download_buffer_;
|
||||
DownloadSource source_{download_buffer_};
|
||||
/**
|
||||
* This is the *initial* size of the download buffer, not the current size.
|
||||
* The download buffer can be resized at runtime; the download_buffer_initial_size_
|
||||
@@ -109,6 +129,9 @@ class OnlineImage final : public PollingComponent,
|
||||
|
||||
uint32_t start_time_{0};
|
||||
uint32_t last_data_time_{0};
|
||||
uint32_t last_progress_time_{0};
|
||||
bool streaming_{false}; // The decoder reads the download itself, as it arrives
|
||||
bool stream_started_{false}; // The decoder has read the headers
|
||||
};
|
||||
|
||||
} // namespace esphome::online_image
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "runtime_image.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
@@ -11,8 +10,8 @@ ESPHOME_LOG_TAG(TAG, "image_decoder");
|
||||
bool ImageDecoder::set_size(int width, int height) {
|
||||
bool success = this->image_->resize(width, height) > 0;
|
||||
this->size_valid_ = success;
|
||||
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
|
||||
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
|
||||
this->draw_width_ = width;
|
||||
this->draw_height_ = height;
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -20,10 +19,24 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
|
||||
if (!this->size_valid_) {
|
||||
return;
|
||||
}
|
||||
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
|
||||
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
|
||||
for (int i = x * this->x_scale_; i < width; i++) {
|
||||
for (int j = y * this->y_scale_; j < height; j++) {
|
||||
// When shrinking, each target pixel is painted once, by the first source pixel that reaches it, rather than by every
|
||||
// pixel that lands on it. Integer maths: the S3 has no double FPU.
|
||||
const int buffer_width = this->image_->get_buffer_width();
|
||||
const int buffer_height = this->image_->get_buffer_height();
|
||||
int x1 = x;
|
||||
int y1 = y;
|
||||
int x2 = x + w;
|
||||
int y2 = y + h;
|
||||
if (buffer_width != this->draw_width_ || buffer_height != this->draw_height_) {
|
||||
x1 = scale_boundary(x, this->draw_width_, buffer_width);
|
||||
y1 = scale_boundary(y, this->draw_height_, buffer_height);
|
||||
x2 = scale_boundary(x + w, this->draw_width_, buffer_width);
|
||||
y2 = scale_boundary(y + h, this->draw_height_, buffer_height);
|
||||
}
|
||||
x2 = std::min(buffer_width, x2);
|
||||
y2 = std::min(buffer_height, y2);
|
||||
for (int i = x1; i < x2; i++) {
|
||||
for (int j = y1; j < y2; j++) {
|
||||
this->image_->draw_pixel(i, j, color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
|
||||
namespace esphome::runtime_image {
|
||||
|
||||
/// The first of `target` pixels that pixel n of `source` paints, rounded up: ceil(n * target / source). The spans of
|
||||
/// neighbouring pixels meet without overlapping, so a shrunk image paints each target pixel once.
|
||||
inline int scale_boundary(int n, int source, int target) {
|
||||
if (source <= 0)
|
||||
return 0;
|
||||
return static_cast<int>((static_cast<uint32_t>(n) * static_cast<uint32_t>(target) + source - 1) /
|
||||
static_cast<uint32_t>(source));
|
||||
}
|
||||
|
||||
enum DecodeError : int {
|
||||
DECODE_ERROR_INVALID_TYPE = -1,
|
||||
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
|
||||
@@ -83,8 +92,8 @@ class ImageDecoder {
|
||||
this->expected_size_ = 0;
|
||||
this->decoded_bytes_ = 0;
|
||||
this->size_valid_ = true;
|
||||
this->x_scale_ = 1.0;
|
||||
this->y_scale_ = 1.0;
|
||||
this->draw_width_ = 0;
|
||||
this->draw_height_ = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,8 +193,9 @@ class ImageDecoder {
|
||||
uint8_t *image_buffer_() const;
|
||||
bool image_is_big_endian_() const;
|
||||
|
||||
double x_scale_ = 1.0;
|
||||
double y_scale_ = 1.0;
|
||||
// Size of the image as the decoder draws it, which draw() spreads over the buffer
|
||||
int draw_width_ = 0;
|
||||
int draw_height_ = 0;
|
||||
RuntimeImage *image_;
|
||||
size_t expected_size_ = 0; // Expected data size (0 if unknown)
|
||||
size_t decoded_bytes_ = 0; // Bytes processed so far
|
||||
|
||||
@@ -28,16 +28,14 @@ bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
|
||||
this->source_height_ = height;
|
||||
this->x_boundaries_.clear();
|
||||
this->y_boundaries_.clear();
|
||||
const uint32_t target_width = this->image_->get_buffer_width();
|
||||
const uint32_t target_height = this->image_->get_buffer_height();
|
||||
// Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source
|
||||
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6;
|
||||
// the generic draw() path rounds down and keeps the odd ones instead
|
||||
for (uint32_t x = 0; x <= static_cast<uint32_t>(width); x++) {
|
||||
this->x_boundaries_.push_back(static_cast<uint16_t>((x * target_width + width - 1) / width));
|
||||
const int target_width = this->image_->get_buffer_width();
|
||||
const int target_height = this->image_->get_buffer_height();
|
||||
// The same boundaries as the generic draw() path, so an 8 to 4 scale keeps columns 0, 2, 4 and 6 in both
|
||||
for (int x = 0; x <= width; x++) {
|
||||
this->x_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(x, width, target_width)));
|
||||
}
|
||||
for (uint32_t y = 0; y <= static_cast<uint32_t>(height); y++) {
|
||||
this->y_boundaries_.push_back(static_cast<uint16_t>((y * target_height + height - 1) / height));
|
||||
for (int y = 0; y <= height; y++) {
|
||||
this->y_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(y, height, target_height)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -138,7 +136,7 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
const int err = this->start_();
|
||||
if (err != 0)
|
||||
return err;
|
||||
if (!this->jpeg_.decode(0, 0, 0))
|
||||
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
this->decoded_bytes_ = size;
|
||||
this->jpeg_.close();
|
||||
@@ -165,10 +163,28 @@ int JpegDecoder::start_() {
|
||||
} else {
|
||||
this->jpeg_.setPixelType(RGB8888);
|
||||
}
|
||||
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
|
||||
const int width = this->jpeg_.getWidth();
|
||||
const int height = this->jpeg_.getHeight();
|
||||
if (!this->set_size(width, height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
|
||||
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
|
||||
// many pixels as the buffer, so a large image shown small is not decoded in full only to be thrown away.
|
||||
const int buffer_width = this->image_->get_buffer_width();
|
||||
const int buffer_height = this->image_->get_buffer_height();
|
||||
this->scale_ = 1;
|
||||
while (this->scale_ < JPEG_SCALE_EIGHTH && width / (this->scale_ * 2) >= buffer_width &&
|
||||
height / (this->scale_ * 2) >= buffer_height) {
|
||||
this->scale_ *= 2;
|
||||
}
|
||||
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
|
||||
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
|
||||
this->draw_width_ = decoded_width;
|
||||
this->draw_height_ = decoded_height;
|
||||
if (this->scale_ != 1) {
|
||||
ESP_LOGD(TAG, "Decoding at 1/%d size, %d x %d", this->scale_, decoded_width, decoded_height);
|
||||
}
|
||||
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(decoded_width, decoded_height)) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
return 0;
|
||||
@@ -252,7 +268,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
|
||||
this->source_ = nullptr;
|
||||
return err;
|
||||
}
|
||||
if (!this->jpeg_.decodeBegin(0, 0, 0))
|
||||
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
|
||||
return this->fail_();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -31,12 +31,16 @@ class JpegDecoder : public ImageDecoder {
|
||||
int HOT decode_step() override;
|
||||
|
||||
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
|
||||
/// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8
|
||||
int get_scale() const { return this->scale_; }
|
||||
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
|
||||
|
||||
protected:
|
||||
bool prepare_rgb565_scaling_(int width, int height);
|
||||
/// Set up an opened image for decoding. Returns 0, or a DecodeError.
|
||||
int start_();
|
||||
/// The JPEGDEC option for scale_: JPEG_SCALE_HALF, _QUARTER and _EIGHTH are the divisors themselves
|
||||
int scale_option_() const { return this->scale_ == 1 ? 0 : this->scale_; }
|
||||
/// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image
|
||||
int fail_();
|
||||
|
||||
@@ -49,6 +53,7 @@ class JpegDecoder : public ImageDecoder {
|
||||
FixedVector<uint16_t> y_boundaries_{};
|
||||
int source_width_{0};
|
||||
int source_height_{0};
|
||||
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
|
||||
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
|
||||
bool direct_rgb565_{false};
|
||||
bool source_failed_{false};
|
||||
|
||||
@@ -116,7 +116,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
|
||||
}
|
||||
case image::IMAGE_TYPE_GRAYSCALE: {
|
||||
uint32_t pos = this->get_position_(x, y);
|
||||
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
|
||||
// 0.2125, 0.7154 and 0.0721 in 1/256ths, summing to 256 so white stays 255. Integer maths: the S3 has no
|
||||
// double FPU
|
||||
auto gray = static_cast<uint8_t>((54 * color.r + 183 * color.g + 19 * color.b) >> 8);
|
||||
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
|
||||
if (gray == 1) {
|
||||
gray = 0;
|
||||
|
||||
@@ -19,6 +19,11 @@ image:
|
||||
} else {
|
||||
ESP_LOGD("online_image", "Cache miss: fresh download");
|
||||
}
|
||||
on_download_started:
|
||||
- logger.log: Download started
|
||||
on_download_progress:
|
||||
lambda: |-
|
||||
ESP_LOGD("online_image", "Downloaded %.0f%% (%zu bytes)", progress, bytes_read);
|
||||
- platform: online_image
|
||||
id: online_binary_transparent_image
|
||||
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
@@ -27,6 +28,9 @@ class FakeContainer : public http_request::HttpContainer {
|
||||
}
|
||||
|
||||
int read(uint8_t *buf, size_t max_len) override {
|
||||
this->largest_read = std::max(this->largest_read, max_len);
|
||||
if (this->read_error_)
|
||||
return -1;
|
||||
size_t len = std::min(max_len, this->available_ - this->bytes_read_);
|
||||
std::memcpy(buf, this->body_.data() + this->bytes_read_, len);
|
||||
this->bytes_read_ += len;
|
||||
@@ -42,13 +46,21 @@ class FakeContainer : public http_request::HttpContainer {
|
||||
}
|
||||
|
||||
void set_content_length(size_t length) { this->content_length = length; }
|
||||
/// Make every read from now on fail, like a dropped connection
|
||||
void fail_reads() { this->read_error_ = true; }
|
||||
/// Let len more bytes of the body arrive
|
||||
void deliver(size_t len) { this->available_ = std::min(this->body_.size(), this->available_ + len); }
|
||||
void add_response_header(const std::string &name, const std::string &value) {
|
||||
this->response_headers_.push_back({name, value});
|
||||
}
|
||||
|
||||
/// The most bytes any one read() asked for
|
||||
size_t largest_read{0};
|
||||
|
||||
protected:
|
||||
std::vector<uint8_t> body_;
|
||||
size_t available_;
|
||||
bool read_error_{false};
|
||||
};
|
||||
|
||||
class FakeHttpRequest : public http_request::HttpRequestComponent {
|
||||
@@ -123,13 +135,173 @@ static constexpr std::array<uint8_t, 69> PNG_1X1{
|
||||
0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9,
|
||||
0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82};
|
||||
|
||||
// 8x8 progressive JPEG, 521 bytes
|
||||
static const uint8_t JPEG_PROGRESSIVE[] = {
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10,
|
||||
0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33,
|
||||
0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67,
|
||||
0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11,
|
||||
0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
|
||||
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00,
|
||||
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x15, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xFF, 0xC4, 0x00, 0x15, 0x01, 0x01, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x05, 0xFF, 0xDA, 0x00, 0x0C, 0x03,
|
||||
0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0x94, 0x05, 0x3F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08,
|
||||
0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F,
|
||||
0x01, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0x7F, 0xFF, 0xC4, 0x00,
|
||||
0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01,
|
||||
0x01, 0x00, 0x01, 0x3F, 0x21, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00,
|
||||
0x10, 0x0B, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00,
|
||||
0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01,
|
||||
0x01, 0x00, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xD9,
|
||||
};
|
||||
|
||||
// 48x40 4:2:0 JPEG (quality 95), 2344 bytes: three rows of 16 pixel MCUs, with the scan starting at byte 609
|
||||
static const uint8_t JPEG_48X40[] = {
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02,
|
||||
0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05,
|
||||
0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x02,
|
||||
0x02, 0x02, 0x02, 0x02, 0x02, 0x05, 0x03, 0x03, 0x05, 0x0A, 0x07, 0x06, 0x07, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
|
||||
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x28, 0x00, 0x30, 0x03, 0x01, 0x22, 0x00,
|
||||
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01,
|
||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
||||
0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00,
|
||||
0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
|
||||
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62,
|
||||
0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A,
|
||||
0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85,
|
||||
0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6,
|
||||
0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7,
|
||||
0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7,
|
||||
0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00,
|
||||
0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03,
|
||||
0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
|
||||
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
|
||||
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09,
|
||||
0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A,
|
||||
0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A,
|
||||
0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96,
|
||||
0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7,
|
||||
0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8,
|
||||
0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9,
|
||||
0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xFC, 0xAA, 0xB8, 0xB7,
|
||||
0xD3, 0xB5, 0xD8, 0x1E, 0xD6, 0xD1, 0xA5, 0x5B, 0x4B, 0x46, 0x10, 0xC1, 0x06, 0xA1, 0x29, 0x69, 0x25, 0xB7, 0xC2,
|
||||
0xC6, 0x0C, 0x71, 0x26, 0x5E, 0x50, 0x15, 0x5D, 0xB6, 0x9C, 0x9E, 0x00, 0xC1, 0x67, 0x67, 0x4D, 0xCF, 0x84, 0xF7,
|
||||
0x57, 0xA6, 0xDA, 0x08, 0x52, 0xF5, 0xE5, 0xBA, 0xB8, 0x7B, 0x5B, 0x68, 0xEE, 0x2F, 0x66, 0x89, 0xB7, 0xB7, 0x9B,
|
||||
0xE5, 0x9C, 0x23, 0xAA, 0xFE, 0xF7, 0x68, 0x11, 0x9E, 0x5D, 0x18, 0x12, 0x48, 0x56, 0x5C, 0x9F, 0x57, 0xB6, 0xF8,
|
||||
0x33, 0xF0, 0xE2, 0x5B, 0xFB, 0xCF, 0xF4, 0x51, 0x0C, 0xC6, 0x64, 0x30, 0x49, 0x71, 0x7C, 0xE2, 0x16, 0x90, 0x2E,
|
||||
0xF4, 0x72, 0xEA, 0xE0, 0x34, 0x81, 0x77, 0x93, 0x9C, 0x07, 0xC2, 0x93, 0xB9, 0x61, 0xC0, 0xD3, 0xF0, 0x17, 0xC1,
|
||||
0xBF, 0x03, 0xCD, 0xE1, 0xAD, 0x42, 0x3F, 0x0A, 0xD8, 0xC7, 0x2D, 0xAC, 0x36, 0xCB, 0xF6, 0x79, 0x22, 0x69, 0x84,
|
||||
0xC4, 0x93, 0x29, 0x2A, 0x89, 0xB9, 0x00, 0x50, 0x62, 0x7C, 0xFF, 0x00, 0x16, 0xD9, 0x65, 0x00, 0x16, 0x65, 0x07,
|
||||
0xFA, 0x5B, 0x36, 0xE2, 0xAC, 0xBF, 0x2D, 0x55, 0x79, 0x29, 0xB5, 0x26, 0x9B, 0xA9, 0x18, 0xB5, 0x15, 0x36, 0x9B,
|
||||
0x53, 0xD1, 0xCA, 0x10, 0x93, 0x69, 0xB6, 0x94, 0x27, 0x16, 0xAF, 0x19, 0x29, 0xB8, 0xBB, 0x3F, 0x9A, 0xE1, 0x1E,
|
||||
0x2A, 0xCB, 0x9E, 0x79, 0x4A, 0x92, 0x84, 0xDA, 0xD6, 0xED, 0xA4, 0xED, 0x7A, 0x72, 0xB5, 0xAC, 0xF6, 0xEB, 0xAD,
|
||||
0xD5, 0x97, 0x91, 0xC9, 0x4D, 0xA4, 0xE9, 0xD7, 0x56, 0x45, 0x66, 0xD3, 0x9D, 0x85, 0xB1, 0x86, 0x59, 0x7E, 0xD9,
|
||||
0x7E, 0xA0, 0xE0, 0xA8, 0x02, 0x17, 0x02, 0x3C, 0x36, 0x59, 0x18, 0xE2, 0x42, 0x1D, 0x40, 0x56, 0x0A, 0x77, 0x30,
|
||||
0xAE, 0xFA, 0x56, 0xD4, 0xA4, 0x8A, 0xD9, 0xAD, 0xD6, 0x38, 0x1D, 0x7F, 0x77, 0x77, 0x1C, 0xB2, 0x4C, 0x22, 0x8C,
|
||||
0x2B, 0xF3, 0x14, 0x64, 0x45, 0x85, 0x52, 0x47, 0x92, 0x7E, 0xF2, 0x0F, 0x34, 0x1D, 0xBC, 0x56, 0xDE, 0x85, 0xF0,
|
||||
0x4B, 0xE1, 0x0A, 0xE9, 0xB0, 0xDB, 0x2D, 0x8C, 0x4C, 0xCD, 0x74, 0xF1, 0x42, 0x96, 0x93, 0xCF, 0xF6, 0x54, 0x0C,
|
||||
0xB3, 0x44, 0x03, 0xAA, 0x6C, 0x49, 0x10, 0x87, 0x00, 0x88, 0xC2, 0xEE, 0x66, 0x6F, 0x9B, 0xAB, 0x57, 0xA4, 0x68,
|
||||
0xDF, 0x0A, 0x3E, 0x1B, 0xDA, 0xE9, 0xDF, 0xD9, 0x96, 0xFE, 0x03, 0xB2, 0x8E, 0xD9, 0x8A, 0xDD, 0xC1, 0x77, 0x14,
|
||||
0x97, 0x50, 0x2E, 0xE3, 0x20, 0x62, 0xA0, 0x33, 0x0C, 0xC6, 0x58, 0x48, 0x36, 0x6D, 0xC1, 0x20, 0xA2, 0x2B, 0x00,
|
||||
0xB5, 0xF1, 0x99, 0xCF, 0x19, 0x65, 0x79, 0xAD, 0x0C, 0x3C, 0x2B, 0x61, 0xF9, 0x28, 0xD6, 0x52, 0x97, 0x35, 0x95,
|
||||
0xD4, 0x61, 0x28, 0x5B, 0x99, 0xB8, 0xA9, 0xA9, 0x47, 0xD9, 0x3A, 0x69, 0xA9, 0xC6, 0x51, 0x52, 0x6A, 0x0D, 0x4A,
|
||||
0x74, 0xA9, 0xC3, 0xFA, 0x7F, 0xC3, 0xFE, 0x25, 0xC1, 0x4E, 0xAE, 0x26, 0x2E, 0x13, 0x71, 0x5C, 0x8D, 0xFB, 0xB1,
|
||||
0xD6, 0xEA, 0x6F, 0xF9, 0xAF, 0xBE, 0xB7, 0x4B, 0x55, 0x1D, 0x75, 0x4C, 0xF3, 0xDF, 0x05, 0xD9, 0xF8, 0x7B, 0xC3,
|
||||
0xBA, 0xB5, 0xF1, 0xBC, 0x8D, 0x63, 0xDF, 0x1C, 0x32, 0x88, 0x1A, 0xF3, 0x74, 0x73, 0x89, 0x15, 0xC4, 0x8A, 0x25,
|
||||
0x66, 0xDC, 0xAA, 0x98, 0x60, 0x06, 0x7F, 0xBD, 0xF2, 0x81, 0xF3, 0x36, 0xFE, 0x9D, 0xAF, 0x78, 0x8F, 0x54, 0xD3,
|
||||
0xAD, 0xA6, 0x9E, 0xC5, 0xE5, 0x9C, 0x96, 0xDC, 0xB7, 0xE8, 0x84, 0x47, 0xFB, 0xB4, 0x54, 0x42, 0x0F, 0x03, 0xF8,
|
||||
0xD7, 0xE5, 0x39, 0x60, 0xF8, 0x04, 0x79, 0x7B, 0xE3, 0xF4, 0x9F, 0x85, 0x5F, 0x08, 0xBE, 0x0D, 0x2C, 0x57, 0xCE,
|
||||
0x6C, 0xA5, 0x2B, 0xA8, 0x08, 0x20, 0x82, 0x04, 0xB9, 0xB8, 0x82, 0x43, 0x28, 0x72, 0x55, 0xD5, 0x83, 0xB9, 0x05,
|
||||
0xC2, 0x44, 0x55, 0x77, 0x8C, 0xFC, 0xEC, 0xD8, 0xCB, 0xE7, 0xBE, 0x9B, 0xE0, 0x5F, 0xC3, 0x9D, 0x32, 0xD4, 0x45,
|
||||
0x26, 0x8B, 0x2C, 0x93, 0xC0, 0xF3, 0x95, 0x49, 0xEE, 0x2E, 0x0C, 0x26, 0x26, 0x31, 0xC9, 0xF3, 0xED, 0x95, 0x96,
|
||||
0x32, 0xEC, 0x10, 0xE1, 0xB8, 0x60, 0x3A, 0x90, 0x1B, 0x3F, 0x9E, 0x66, 0x58, 0xFC, 0x14, 0x31, 0x15, 0xFD, 0x9C,
|
||||
0x79, 0x57, 0x3A, 0x92, 0x84, 0x1C, 0x3D, 0xC9, 0xCD, 0x27, 0x6D, 0x12, 0xE5, 0xE7, 0xA6, 0xD5, 0x4D, 0x64, 0x93,
|
||||
0xD1, 0x45, 0xC6, 0xAD, 0xE7, 0x2F, 0xE9, 0xEE, 0x17, 0xE3, 0x0C, 0x0A, 0xE1, 0x9C, 0x37, 0x34, 0x67, 0x2B, 0x73,
|
||||
0x2B, 0xB4, 0xBA, 0x49, 0xF4, 0x72, 0x57, 0x51, 0xDD, 0x24, 0x9B, 0x5A, 0x36, 0x9A, 0x49, 0x1E, 0x15, 0xA7, 0x7E,
|
||||
0xCE, 0xF6, 0x89, 0x6A, 0x96, 0x43, 0x55, 0xB5, 0xB3, 0xB6, 0xB8, 0x96, 0x2B, 0x9B, 0x78, 0xA0, 0xB4, 0x9E, 0xE5,
|
||||
0x61, 0x90, 0xE0, 0xC2, 0xEC, 0x61, 0x91, 0x47, 0xCC, 0xDB, 0xF1, 0x80, 0xC0, 0x98, 0xD4, 0xE5, 0xDC, 0x64, 0xEC,
|
||||
0x7C, 0x29, 0xFD, 0x98, 0x6C, 0xAF, 0xEC, 0xF5, 0x76, 0xB7, 0xF1, 0x36, 0x9A, 0x5D, 0x6F, 0x96, 0xE1, 0x74, 0xB5,
|
||||
0xD3, 0x44, 0x91, 0xA8, 0x45, 0x0C, 0xE2, 0x3D, 0xAC, 0x85, 0xF9, 0x3B, 0x5B, 0x19, 0x8A, 0x43, 0x1F, 0xCA, 0x50,
|
||||
0x9D, 0x87, 0xD2, 0x0F, 0xC2, 0x1F, 0x89, 0x4F, 0x6D, 0x04, 0x0F, 0xE1, 0x61, 0xAA, 0xEE, 0xB4, 0x8E, 0x56, 0xFB,
|
||||
0x06, 0xAF, 0x04, 0xF3, 0x40, 0x9B, 0x55, 0x4E, 0xCB, 0x66, 0x91, 0xCB, 0x18, 0xC2, 0x88, 0xB6, 0xB1, 0x65, 0x60,
|
||||
0xA4, 0x6E, 0xDC, 0xA4, 0x9E, 0xAF, 0xE1, 0xBF, 0xC2, 0x2F, 0x89, 0xA9, 0xE1, 0xFB, 0x8D, 0x6E, 0xC2, 0x3B, 0x4B,
|
||||
0x30, 0xD7, 0x60, 0xDA, 0x41, 0x2D, 0xCC, 0x6C, 0x2E, 0x49, 0xF3, 0x1E, 0x42, 0x19, 0x5D, 0x58, 0x3C, 0x8A, 0x81,
|
||||
0x82, 0xB8, 0x0C, 0xC9, 0xF2, 0xE5, 0x8F, 0x03, 0x8F, 0x36, 0xE3, 0x8A, 0x98, 0xCC, 0x14, 0xAB, 0x62, 0x2B, 0xC2,
|
||||
0x34, 0xA2, 0xA5, 0x78, 0x37, 0x42, 0x12, 0x70, 0x8C, 0x62, 0xD2, 0x6D, 0x72, 0x7B, 0xAF, 0x9B, 0xDD, 0xFD, 0xE5,
|
||||
0x4E, 0x79, 0x49, 0x45, 0x73, 0xA9, 0x72, 0x2F, 0xF0, 0x73, 0x83, 0xF8, 0xCE, 0xB4, 0x33, 0xE8, 0x3F, 0xAE, 0x42,
|
||||
0xF7, 0x76, 0xB3, 0x83, 0x57, 0xE5, 0x95, 0xBE, 0xCE, 0x8F, 0x7B, 0xDE, 0xD7, 0x4B, 0x6B, 0x9C, 0x1E, 0x8D, 0xF0,
|
||||
0x56, 0x7B, 0xDD, 0x26, 0x34, 0x5F, 0x1F, 0xDB, 0x9D, 0x37, 0x53, 0xB8, 0x96, 0xED, 0xA0, 0x16, 0xB1, 0xC9, 0x2C,
|
||||
0x8B, 0xF2, 0x26, 0x58, 0x89, 0x37, 0xEE, 0x04, 0xB2, 0x97, 0x54, 0x2D, 0x88, 0xC0, 0x60, 0x0B, 0x03, 0x5E, 0x9B,
|
||||
0x67, 0xFB, 0x36, 0xE9, 0x31, 0x2D, 0x9C, 0x37, 0x9E, 0x32, 0x84, 0x5D, 0x2C, 0x4A, 0xD6, 0xAD, 0x73, 0x63, 0x28,
|
||||
0x36, 0xAA, 0x53, 0xF7, 0x90, 0x29, 0x8E, 0x5C, 0x92, 0xBE, 0x41, 0xDC, 0xC0, 0xB3, 0x2B, 0x46, 0x32, 0x4B, 0x96,
|
||||
0x35, 0xAD, 0x1F, 0xC2, 0xDF, 0x89, 0x17, 0xF6, 0xB6, 0xFA, 0xB5, 0xED, 0xB8, 0xB7, 0x8E, 0x78, 0x66, 0x96, 0x09,
|
||||
0x6C, 0x6E, 0x23, 0x32, 0x28, 0x08, 0xE8, 0xF2, 0xBA, 0xF9, 0x86, 0x48, 0xC0, 0x68, 0xFE, 0x63, 0xC8, 0x23, 0x82,
|
||||
0xCC, 0xA5, 0x4A, 0xFA, 0x66, 0x8F, 0xF0, 0x37, 0xE2, 0x6D, 0xE5, 0xC9, 0x3A, 0x87, 0x86, 0xB4, 0xFB, 0x28, 0xAE,
|
||||
0x61, 0x0F, 0x0B, 0x59, 0xDF, 0xC0, 0xD1, 0xAC, 0xE0, 0x24, 0x41, 0x42, 0xC4, 0x49, 0x1B, 0x10, 0x21, 0x50, 0xC0,
|
||||
0x09, 0x11, 0x8B, 0xEE, 0x6E, 0x71, 0xE2, 0xE2, 0x78, 0xBB, 0x33, 0xFA, 0xC5, 0x1A, 0xB4, 0xEA, 0xC6, 0x74, 0xD2,
|
||||
0x8C, 0x24, 0xA5, 0x2A, 0x3C, 0xB5, 0x2E, 0xB9, 0x9B, 0x71, 0x97, 0x34, 0xE0, 0xAA, 0x54, 0x93, 0x71, 0x56, 0x49,
|
||||
0xB9, 0xC6, 0x4E, 0x9A, 0x9B, 0xD3, 0xFA, 0x7F, 0x81, 0xB8, 0xD6, 0xAC, 0xDD, 0x6F, 0x65, 0x8D, 0x82, 0x77, 0x85,
|
||||
0x95, 0xE1, 0x7D, 0x1C, 0xD2, 0x8B, 0x7A, 0x27, 0xA2, 0xF4, 0xED, 0xEE, 0x9E, 0x67, 0xE0, 0x8F, 0x81, 0x37, 0xBA,
|
||||
0xEF, 0x87, 0x67, 0xBF, 0x8F, 0xC4, 0xD2, 0xDD, 0x4F, 0x05, 0xA8, 0x61, 0x7B, 0x1E, 0x9E, 0xE1, 0x8C, 0xEA, 0xF3,
|
||||
0x18, 0x99, 0xA3, 0xDE, 0x76, 0x66, 0x36, 0x2A, 0x30, 0xFB, 0x9B, 0x0F, 0xC3, 0x1C, 0xA3, 0x76, 0x4B, 0xFB, 0x3B,
|
||||
0xE8, 0xBA, 0x66, 0xBF, 0x0E, 0x93, 0xFD, 0xAB, 0x23, 0xD9, 0xA5, 0xCE, 0x62, 0xF3, 0x34, 0xEB, 0x7F, 0x29, 0x0A,
|
||||
0xA7, 0x04, 0x48, 0xF2, 0x79, 0x82, 0x42, 0x1D, 0x46, 0x4B, 0xA9, 0x1B, 0xD1, 0xBE, 0x52, 0x54, 0xD7, 0xA8, 0xF8,
|
||||
0x33, 0xE1, 0xAF, 0xC4, 0xA8, 0x24, 0xD6, 0x6D, 0xED, 0xBC, 0x0A, 0xD3, 0x5A, 0x5C, 0xC4, 0xDF, 0x64, 0x17, 0x97,
|
||||
0x36, 0xF0, 0x65, 0x52, 0x39, 0xD4, 0x23, 0x22, 0x9D, 0xD8, 0x64, 0x83, 0x04, 0x85, 0x38, 0xEF, 0xB8, 0x49, 0x91,
|
||||
0xBB, 0xFF, 0x00, 0x0A, 0xDF, 0xE3, 0x77, 0x84, 0xAC, 0xD3, 0x4A, 0xBC, 0xF0, 0x3A, 0x5A, 0x5B, 0x4F, 0x72, 0x5E,
|
||||
0xD7, 0x53, 0x8E, 0xE2, 0x12, 0x6D, 0xC6, 0x40, 0x20, 0x47, 0xE6, 0x3E, 0x71, 0x26, 0x70, 0x24, 0xE4, 0xB4, 0x63,
|
||||
0x2A, 0xC0, 0xE0, 0x7E, 0x71, 0x98, 0x71, 0x35, 0x5C, 0x4E, 0x69, 0x5A, 0xBD, 0x7C, 0x7A, 0x9C, 0xE3, 0xC9, 0xCF,
|
||||
0x38, 0x46, 0x8D, 0xA4, 0xDC, 0x63, 0x38, 0x4A, 0xE9, 0xED, 0x5A, 0x4E, 0x4A, 0x6D, 0xF3, 0xCA, 0x2D, 0xB9, 0x4D,
|
||||
0xD3, 0x77, 0x8C, 0x7F, 0xA8, 0xF8, 0x53, 0x8D, 0x31, 0x32, 0xC8, 0xA9, 0xDB, 0x15, 0x04, 0x9F, 0xB4, 0xD2, 0xF4,
|
||||
0xD3, 0xF8, 0xA5, 0x77, 0xCD, 0xB3, 0x49, 0x6C, 0xB4, 0xEA, 0x97, 0x46, 0x78, 0x45, 0xC4, 0x16, 0x5A, 0xDD, 0xA5,
|
||||
0xA6, 0x8D, 0xA7, 0xEA, 0x77, 0x4A, 0x54, 0x47, 0x3C, 0x31, 0x41, 0x20, 0x06, 0x68, 0x00, 0x9A, 0x4F, 0x2A, 0x40,
|
||||
0x5D, 0x5F, 0x69, 0xF3, 0x15, 0xCB, 0x32, 0x96, 0xDC, 0xC0, 0x90, 0xA5, 0xF0, 0xBD, 0xAF, 0xC3, 0x96, 0x83, 0x51,
|
||||
0xB8, 0xD6, 0xA7, 0x48, 0xD2, 0xE8, 0x2B, 0x2C, 0x2D, 0x6B, 0xF6, 0xA4, 0x55, 0x8E, 0x45, 0x5F, 0xBA, 0x54, 0x2A,
|
||||
0x36, 0x77, 0x12, 0x72, 0xE5, 0x9C, 0x14, 0x3B, 0x63, 0x6C, 0x62, 0x8A, 0x2B, 0xDE, 0xCD, 0x70, 0x74, 0xA7, 0x92,
|
||||
0xE2, 0x31, 0x35, 0x2D, 0x29, 0x54, 0xBB, 0x97, 0xBB, 0x18, 0xF3, 0x38, 0x52, 0xA5, 0x38, 0xDF, 0x92, 0x31, 0x7E,
|
||||
0xEB, 0x9B, 0x6A, 0xCD, 0x6B, 0xEF, 0x7C, 0x6E, 0x52, 0x7F, 0xE1, 0xF7, 0x0E, 0x54, 0x96, 0x1B, 0x3D, 0x52, 0x86,
|
||||
0xF0, 0x73, 0x4B, 0xEE, 0x92, 0xD6, 0xD6, 0xBF, 0xC2, 0x8D, 0x9F, 0x0B, 0xDE, 0xEB, 0xF3, 0x69, 0x6B, 0x06, 0x9B,
|
||||
0xA9, 0x5B, 0xE9, 0xF6, 0x6F, 0x76, 0x1E, 0x45, 0x8E, 0xE1, 0x54, 0xC8, 0xCD, 0xF3, 0x28, 0x8B, 0x1C, 0x9E, 0x25,
|
||||
0x55, 0xDA, 0xA5, 0x58, 0x80, 0xCA, 0x46, 0x42, 0xA8, 0xF4, 0x1B, 0xEB, 0xBB, 0xBB, 0x8B, 0x69, 0xAF, 0xA5, 0x8E,
|
||||
0xD0, 0x49, 0x25, 0x9C, 0x37, 0x73, 0xE9, 0xD7, 0xF2, 0x14, 0x8E, 0x55, 0x91, 0x64, 0x90, 0x46, 0xA8, 0xF9, 0x50,
|
||||
0x57, 0x73, 0x2E, 0xE0, 0x55, 0xF6, 0x87, 0x45, 0x20, 0x2E, 0xC7, 0x28, 0xAF, 0xCD, 0x78, 0xA6, 0xA5, 0x5C, 0x26,
|
||||
0x33, 0x01, 0x35, 0x2E, 0x67, 0x52, 0x35, 0xA0, 0xEE, 0x93, 0x56, 0x70, 0x4F, 0x48, 0xDB, 0x95, 0x6B, 0x76, 0xF4,
|
||||
0xB4, 0xA5, 0x27, 0x29, 0x29, 0x35, 0x16, 0xBF, 0xA8, 0x7C, 0x3E, 0x92, 0xF6, 0x98, 0xA8, 0x5B, 0x4B, 0xC5, 0xDB,
|
||||
0x5E, 0x8E, 0x4F, 0x7D, 0xFA, 0xDB, 0x57, 0xB2, 0x56, 0xD6, 0xED, 0xF7, 0x3E, 0x10, 0xB2, 0x82, 0xF6, 0xC2, 0xF2,
|
||||
0xF3, 0x4E, 0xD4, 0xAF, 0xEE, 0x5A, 0xCA, 0xCE, 0xD2, 0xF1, 0xA7, 0xB8, 0x9D, 0xD4, 0x42, 0x8C, 0xF2, 0x3C, 0x8E,
|
||||
0x42, 0x02, 0x24, 0xFD, 0xDA, 0x96, 0x60, 0xAD, 0xB0, 0xC9, 0x0E, 0x00, 0xC8, 0xC8, 0xEA, 0xEE, 0x12, 0x7B, 0x09,
|
||||
0x6C, 0x6E, 0xCE, 0xA2, 0x0B, 0xAA, 0x32, 0xD9, 0xB9, 0x49, 0x66, 0xDF, 0x74, 0xCC, 0x31, 0x1B, 0xE4, 0xE6, 0x63,
|
||||
0xF2, 0x91, 0x95, 0x42, 0x15, 0x8B, 0x7D, 0xE0, 0xAC, 0xB4, 0x51, 0x5F, 0x33, 0x3A, 0xD8, 0x8C, 0x46, 0x7A, 0x9C,
|
||||
0xA6, 0xD7, 0x2C, 0x13, 0x49, 0x25, 0x6E, 0x6A, 0x73, 0xE4, 0x84, 0x9E, 0x9A, 0xC9, 0x29, 0x24, 0x9E, 0xE9, 0x42,
|
||||
0x11, 0x8D, 0xA2, 0x9C, 0x5F, 0xF5, 0x37, 0x0D, 0x36, 0xF8, 0x7E, 0x9A, 0xEC, 0xE5, 0xE5, 0x7E, 0x67, 0x77, 0x7B,
|
||||
0x7A, 0xBF, 0x5E, 0xB7, 0x3F, 0xFF, 0xD9,
|
||||
};
|
||||
|
||||
static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); }
|
||||
|
||||
class OnlineImageDownloadEnd : public ::testing::Test {
|
||||
protected:
|
||||
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format) {
|
||||
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format, uint32_t buffer_size = 256) {
|
||||
auto img = std::make_unique<OnlineImage>("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB,
|
||||
image::TRANSPARENCY_OPAQUE, nullptr, 256);
|
||||
image::TRANSPARENCY_OPAQUE, nullptr, buffer_size);
|
||||
img->set_parent(&this->http_);
|
||||
return img;
|
||||
}
|
||||
@@ -286,4 +458,294 @@ TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) {
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// The JPEG decoded with all of it in memory at once, to compare the downloaded images with
|
||||
static std::vector<uint8_t> reference_jpeg_pixels() {
|
||||
runtime_image::RuntimeImage ref(runtime_image::JPEG, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
|
||||
std::vector<uint8_t> data(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
|
||||
EXPECT_TRUE(ref.begin_decode(data.size()));
|
||||
EXPECT_EQ(ref.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
|
||||
EXPECT_TRUE(ref.end_decode());
|
||||
return std::vector<uint8_t>(ref.get_data_start(), ref.get_data_start() + ref.get_width_stride() * ref.get_height());
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> image_pixels(OnlineImage &image) {
|
||||
return std::vector<uint8_t>(image.get_data_start(),
|
||||
image.get_data_start() + image.get_width_stride() * image.get_height());
|
||||
}
|
||||
|
||||
// A JPEG of known size is decoded as it arrives, so a buffer smaller than the file only has to hold the headers
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDecodesThroughASmallBuffer) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
// Bytes after the image are never needed. Waiting for the whole file before decoding would read them all.
|
||||
std::vector<uint8_t> body(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
|
||||
body.resize(body.size() + 8192, 0);
|
||||
auto container = std::make_shared<FakeContainer>(body, body.size(), false);
|
||||
container->add_response_header("etag", "\"abc\"");
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_LT(container->get_bytes_read(), body.size()) << "the image was not decoded as it arrived";
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
ASSERT_EQ(image->get_width(), 48);
|
||||
ASSERT_EQ(image->get_height(), 40);
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
|
||||
// A complete image is cached
|
||||
this->http_.next = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
|
||||
image->update();
|
||||
EXPECT_TRUE(this->http_.sent_header("if-none-match"));
|
||||
}
|
||||
|
||||
// A read() waits for every byte it asks for, so a large buffer is filled a little at a time rather than in one
|
||||
// read that blocks the loop until the whole buffer has arrived
|
||||
TEST_F(OnlineImageDownloadEnd, JpegReadsInSmallPiecesWithALargeBuffer) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 65536);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 0, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
|
||||
container->deliver(sizeof(JPEG_48X40));
|
||||
image->loop();
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_LE(container->largest_read, READ_CHUNK);
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
}
|
||||
|
||||
// A downloaded PNG is read a little at a time too
|
||||
TEST_F(OnlineImageDownloadEnd, PngReadsInSmallPiecesWithALargeBuffer) {
|
||||
auto image = this->make_image_(runtime_image::PNG, 65536);
|
||||
std::vector<uint8_t> png(PNG_1X1.begin(), PNG_1X1.end());
|
||||
auto container = std::make_shared<FakeContainer>(png, png.size(), false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_LE(container->largest_read, READ_CHUNK);
|
||||
}
|
||||
|
||||
// A slow link: the decoder waits part way through the image for the rest to arrive
|
||||
TEST_F(OnlineImageDownloadEnd, JpegWaitsForASlowDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1024, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
int passes = 0;
|
||||
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
|
||||
image->loop();
|
||||
if (passes == 5) {
|
||||
EXPECT_EQ(this->finished_, 0) << "the image cannot be finished before the rest of it arrives";
|
||||
}
|
||||
container->deliver(40);
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_GT(passes, 10);
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
}
|
||||
|
||||
// A download that stops part way through times out rather than waiting for ever
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDownloadThatStopsTimesOut) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1500, false);
|
||||
set_loop_time(1000);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(this->errors_, 0) << "still within the timeout";
|
||||
|
||||
set_loop_time(1000 + this->http_.get_timeout());
|
||||
image->loop();
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// Headers that do not fit the download buffer cannot be read as the image arrives, so the whole image is
|
||||
// downloaded first, keeping the bytes already read
|
||||
TEST_F(OnlineImageDownloadEnd, JpegHeadersLargerThanTheBufferDownloadTheWholeImage) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 256);
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
|
||||
image->loop();
|
||||
container->deliver(256);
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(this->errors_, 0);
|
||||
EXPECT_EQ(container->get_bytes_read(), sizeof(JPEG_48X40));
|
||||
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
|
||||
}
|
||||
|
||||
// The pass that opens the image does not decode any of it
|
||||
TEST_F(OnlineImageDownloadEnd, JpegOpensInAPassOfItsOwn) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false);
|
||||
this->start_(*image, container);
|
||||
|
||||
image->loop();
|
||||
EXPECT_EQ(this->finished_ + this->errors_, 0);
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(this->finished_, 1);
|
||||
}
|
||||
|
||||
// A progressive JPEG is refused while it downloads, without blaming the buffer size
|
||||
TEST_F(OnlineImageDownloadEnd, JpegProgressiveReportsError) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 256);
|
||||
std::vector<uint8_t> body(JPEG_PROGRESSIVE, JPEG_PROGRESSIVE + sizeof(JPEG_PROGRESSIVE));
|
||||
auto container = std::make_shared<FakeContainer>(body, 256, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// on_download_started fires once the server has accepted the request, before the first progress report, and not
|
||||
// for a response that has no image to download
|
||||
TEST_F(OnlineImageDownloadEnd, StartedFiresOnlyWhenTheDownloadBegins) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<std::string> events;
|
||||
image->add_on_download_started_callback([&events]() { events.emplace_back("started"); });
|
||||
image->add_on_progress_callback([&events](float, size_t) { events.emplace_back("progress"); });
|
||||
this->start_(*image,
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
|
||||
sizeof(JPEG_48X40), false));
|
||||
ASSERT_GE(events.size(), 2u);
|
||||
EXPECT_EQ(events[0], "started");
|
||||
EXPECT_EQ(events[1], "progress");
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
EXPECT_EQ(std::count(events.begin(), events.end(), std::string("started")), 1);
|
||||
|
||||
for (int status : {304, 404}) {
|
||||
events.clear();
|
||||
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
|
||||
container->status_code = status;
|
||||
this->http_.next = container;
|
||||
image->update();
|
||||
EXPECT_TRUE(events.empty()) << "HTTP " << status;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress starts at 0, is reported at most every 100 ms, and always reaches 100%
|
||||
TEST_F(OnlineImageDownloadEnd, ProgressIsReportedAsTheImageDownloads) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<float> reports;
|
||||
image->add_on_progress_callback([&reports](float progress, size_t) { reports.push_back(progress); });
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 100, false);
|
||||
uint32_t now = 1000;
|
||||
set_loop_time(now);
|
||||
this->start_(*image, container);
|
||||
ASSERT_EQ(reports.size(), 1u);
|
||||
EXPECT_EQ(reports[0], 0.0f);
|
||||
|
||||
int passes = 0;
|
||||
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
|
||||
now += 50;
|
||||
set_loop_time(now);
|
||||
container->deliver(100);
|
||||
image->loop();
|
||||
}
|
||||
|
||||
ASSERT_EQ(this->finished_, 1);
|
||||
EXPECT_EQ(reports.back(), 100.0f);
|
||||
EXPECT_TRUE(std::is_sorted(reports.begin(), reports.end()));
|
||||
// A report every 100 ms of a 50 ms loop, plus the start and the end
|
||||
EXPECT_LE(reports.size(), static_cast<size_t>(passes / 2 + 2));
|
||||
EXPECT_GT(reports.size(), 3u);
|
||||
}
|
||||
|
||||
// Without a Content-Length the percentage is unknown, but the bytes read are still reported
|
||||
TEST_F(OnlineImageDownloadEnd, ProgressOfAChunkedDownloadHasNoPercentage) {
|
||||
auto image = this->make_image_(runtime_image::PNG);
|
||||
std::vector<std::pair<float, size_t>> reports;
|
||||
image->add_on_progress_callback(
|
||||
[&reports](float progress, size_t bytes_read) { reports.emplace_back(progress, bytes_read); });
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(PNG_1X1.begin(), PNG_1X1.end()), PNG_1X1.size(), true);
|
||||
set_loop_time(1000);
|
||||
this->start_(*image, container);
|
||||
set_loop_time(2000);
|
||||
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
|
||||
image->loop();
|
||||
|
||||
ASSERT_GE(reports.size(), 2u);
|
||||
EXPECT_TRUE(std::isnan(reports.back().first));
|
||||
EXPECT_EQ(reports.back().second, PNG_1X1.size());
|
||||
}
|
||||
|
||||
// Scan data that cannot be decoded fails the download part way through
|
||||
TEST_F(OnlineImageDownloadEnd, JpegDecodeErrorFailsTheDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
std::vector<uint8_t> body(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
|
||||
std::fill(body.begin() + 700, body.end() - 2, 0xFE);
|
||||
auto container = std::make_shared<FakeContainer>(body, 1024, false);
|
||||
this->start_(*image, container);
|
||||
|
||||
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
|
||||
image->loop();
|
||||
container->deliver(256);
|
||||
}
|
||||
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
// A read that fails part way through the download ends it with an error
|
||||
TEST_F(OnlineImageDownloadEnd, JpegReadErrorFailsTheDownload) {
|
||||
auto image = this->make_image_(runtime_image::JPEG, 1024);
|
||||
auto container =
|
||||
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1024, false);
|
||||
this->start_(*image, container);
|
||||
for (int i = 0; i < 5; i++)
|
||||
image->loop();
|
||||
ASSERT_EQ(this->errors_, 0);
|
||||
|
||||
container->fail_reads();
|
||||
image->loop();
|
||||
EXPECT_EQ(this->errors_, 1);
|
||||
EXPECT_EQ(this->finished_, 0);
|
||||
}
|
||||
|
||||
TEST(DownloadBufferGrow, KeepsTheUnreadData) {
|
||||
DownloadBuffer buffer(16);
|
||||
std::memcpy(buffer.append(), "abcdef", 6);
|
||||
buffer.write(6);
|
||||
buffer.read(2);
|
||||
|
||||
ASSERT_TRUE(buffer.grow(64));
|
||||
EXPECT_EQ(buffer.size(), 64u);
|
||||
ASSERT_EQ(buffer.unread(), 4u);
|
||||
EXPECT_EQ(std::memcmp(buffer.data(), "cdef", 4), 0);
|
||||
|
||||
// A failed allocation leaves the buffer as it was
|
||||
EXPECT_FALSE(buffer.grow(SIZE_MAX / 2));
|
||||
EXPECT_EQ(buffer.size(), 64u);
|
||||
EXPECT_EQ(std::memcmp(buffer.data(), "cdef", 4), 0);
|
||||
// Growing to a smaller size changes nothing
|
||||
EXPECT_TRUE(buffer.grow(8));
|
||||
EXPECT_EQ(buffer.size(), 64u);
|
||||
}
|
||||
|
||||
} // namespace esphome::online_image::testing
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
@@ -362,6 +365,23 @@ static std::vector<uint8_t> pixel_bytes(TestableRuntimeImage &img) {
|
||||
return std::vector<uint8_t>(start, start + img.get_width_stride() * img.get_height());
|
||||
}
|
||||
|
||||
// Shrinking a PNG keeps the first source pixel of each span, as the JPEG path does: the even rows and columns
|
||||
TEST(RuntimeImageDecoder, ShrunkPngKeepsTheFirstPixelOfEachSpan) {
|
||||
TestableRuntimeImage img(PNG, image::IMAGE_TYPE_RGB, false, 2, 2);
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
|
||||
ASSERT_EQ(img.get_width(), 2);
|
||||
ASSERT_EQ(img.get_height(), 2);
|
||||
for (int row = 0; row < 2; row++) {
|
||||
for (int col = 0; col < 2; col++) {
|
||||
const Color pixel = img.get_pixel(col, row);
|
||||
const uint8_t *expected = PNG_RGB_EXPECTED[row * 2][col * 2];
|
||||
EXPECT_EQ(pixel.r, expected[0]) << col << "," << row;
|
||||
EXPECT_EQ(pixel.g, expected[1]) << col << "," << row;
|
||||
EXPECT_EQ(pixel.b, expected[2]) << col << "," << row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(JPEG);
|
||||
|
||||
@@ -377,6 +397,12 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels";
|
||||
}
|
||||
|
||||
// The RGB565 word at (x, y) of a decoded image, in its own byte order
|
||||
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
|
||||
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
|
||||
return p[0] | (p[1] << 8);
|
||||
}
|
||||
|
||||
// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each
|
||||
// channel is checked within one step; the byte order is still exact
|
||||
static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) {
|
||||
@@ -386,16 +412,12 @@ static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *o
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
// Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those
|
||||
// pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source
|
||||
// pixel (2, 0), which is word 0x3803 here.
|
||||
static const uint16_t EXPECTED[4 * 4] = {
|
||||
0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F,
|
||||
0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17,
|
||||
};
|
||||
// Halving the 8x8 gradient makes JPEGDEC decode it at half size, which averages each 2x2 block of pixels
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
|
||||
TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
|
||||
TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4);
|
||||
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(rgb565_little_endian.get_width(), 4);
|
||||
@@ -405,9 +427,45 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
|
||||
const uint8_t *little = rgb565_little_endian.get_data_start();
|
||||
const uint8_t *big = rgb565_big_endian.get_data_start();
|
||||
for (size_t i = 0; i < 4 * 4; i++) {
|
||||
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i);
|
||||
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i);
|
||||
for (int row = 0; row < 4; row++) {
|
||||
for (int col = 0; col < 4; col++) {
|
||||
int red = 0;
|
||||
int green = 0;
|
||||
int blue = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
const uint16_t word = rgb565_word(full, col * 2 + dx, row * 2 + dy);
|
||||
red += word >> 11;
|
||||
green += (word >> 5) & 0x3F;
|
||||
blue += word & 0x1F;
|
||||
}
|
||||
}
|
||||
const uint16_t expected = ((red + 2) / 4) << 11 | ((green + 2) / 4) << 5 | (blue + 2) / 4;
|
||||
const size_t i = row * 4 + col;
|
||||
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), expected, "little-endian", i);
|
||||
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], expected, "big-endian", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generic draw() path gets JPEGDEC's half size RGB8888 output. It must match the half size RGB565 decode checked
|
||||
// above. A full size RGB8888 decode is no reference: JPEGDEC's x86 SSE code writes it with red and blue swapped.
|
||||
TEST(RuntimeImageDecoder, JpegDecodesToScaledRgb) {
|
||||
TestableRuntimeImage rgb565(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
|
||||
TestableRuntimeImage rgb(JPEG, image::IMAGE_TYPE_RGB, false, 4, 4);
|
||||
ASSERT_TRUE(decode_all(rgb565, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_TRUE(decode_all(rgb, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(static_cast<JpegDecoder *>(rgb.decoder())->get_scale(), 2);
|
||||
|
||||
for (int row = 0; row < 4; row++) {
|
||||
for (int col = 0; col < 4; col++) {
|
||||
const uint16_t word = rgb565_word(rgb565, col, row);
|
||||
const Color pixel = rgb.get_pixel(col, row);
|
||||
// RGB565 keeps the top 5 or 6 bits of each channel
|
||||
EXPECT_LE(std::abs(pixel.r - ((word >> 11) << 3)), 8) << "pixel " << col << "," << row << " red";
|
||||
EXPECT_LE(std::abs(pixel.g - (((word >> 5) & 0x3F) << 2)), 4) << "pixel " << col << "," << row << " green";
|
||||
EXPECT_LE(std::abs(pixel.b - ((word & 0x1F) << 3)), 8) << "pixel " << col << "," << row << " blue";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,10 +473,43 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
|
||||
// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start
|
||||
static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; }
|
||||
|
||||
// The RGB565 word at (x, y) of a decoded image, in its own byte order
|
||||
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
|
||||
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
|
||||
return p[0] | (p[1] << 8);
|
||||
// Every target pixel of a PNG drawn at a size that is not a whole multiple: enlarged with spans 1 or 2 pixels wide, and
|
||||
// shrunk with spans of 0, 1 or 2 pixels
|
||||
TEST(RuntimeImageDecoder, ScaledPngPaintsEachSourcePixelOverItsSpan) {
|
||||
// A fixed size keeps the aspect ratio, so both are square
|
||||
for (auto [width, height] : {std::pair{6, 6}, std::pair{3, 3}}) {
|
||||
TestableRuntimeImage img(PNG, image::IMAGE_TYPE_RGB, false, width, height);
|
||||
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
|
||||
ASSERT_EQ(img.get_width(), width);
|
||||
ASSERT_EQ(img.get_height(), height);
|
||||
int painted = 0;
|
||||
for (int m = 0; m < 4; m++) {
|
||||
for (int n = 0; n < 4; n++) {
|
||||
const uint8_t *expected = PNG_RGB_EXPECTED[m][n];
|
||||
for (int y = scaled_boundary(m, 4, height); y < scaled_boundary(m + 1, 4, height); y++) {
|
||||
for (int x = scaled_boundary(n, 4, width); x < scaled_boundary(n + 1, 4, width); x++) {
|
||||
const Color pixel = img.get_pixel(x, y);
|
||||
EXPECT_EQ(pixel.r, expected[0]) << width << "x" << height << " at " << x << "," << y;
|
||||
EXPECT_EQ(pixel.g, expected[1]) << width << "x" << height << " at " << x << "," << y;
|
||||
EXPECT_EQ(pixel.b, expected[2]) << width << "x" << height << " at " << x << "," << y;
|
||||
painted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_EQ(painted, width * height);
|
||||
}
|
||||
}
|
||||
|
||||
// JPEGDEC decodes at the smallest of full, 1/2, 1/4 and 1/8 size that still covers the buffer
|
||||
TEST(RuntimeImageDecoder, JpegDecodesAtTheSmallestSizeThatCoversTheBuffer) {
|
||||
for (auto [size, scale] :
|
||||
{std::pair{8, 1}, std::pair{5, 1}, std::pair{4, 2}, std::pair{3, 2}, std::pair{2, 4}, std::pair{1, 8}}) {
|
||||
TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
|
||||
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
EXPECT_EQ(img.get_width(), size);
|
||||
EXPECT_EQ(static_cast<JpegDecoder *>(img.decoder())->get_scale(), scale) << size << "x" << size;
|
||||
}
|
||||
}
|
||||
|
||||
// Every scaled direct decode must equal the unscaled one sampled through the boundary table,
|
||||
@@ -427,7 +518,8 @@ TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) {
|
||||
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
|
||||
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
|
||||
for (int size : {4, 6, 16}) {
|
||||
// Sizes JPEGDEC cannot reach by scaling, so the full size decode is spread over the buffer
|
||||
for (int size : {5, 6, 16}) {
|
||||
TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
|
||||
ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
|
||||
ASSERT_EQ(scaled.get_width(), size);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
namespace esphome::runtime_image::testing {
|
||||
|
||||
class GrayscaleImage : public RuntimeImage {
|
||||
public:
|
||||
GrayscaleImage() : RuntimeImage(BMP, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE) {}
|
||||
uint8_t pixel(int x) const { return this->buffer_[x]; }
|
||||
};
|
||||
|
||||
// The integer weights stay within one step of the floating point formula they replace, and keep white at 255
|
||||
TEST(GrayscalePixel, MatchesTheFloatingPointWeights) {
|
||||
GrayscaleImage img;
|
||||
ASSERT_GT(img.resize(256, 1), 0);
|
||||
for (int r = 0; r < 256; r += 5) {
|
||||
for (int g = 0; g < 256; g += 5) {
|
||||
for (int b = 0; b < 256; b += 5) {
|
||||
img.draw_pixel(0, 0, Color(r, g, b));
|
||||
const int expected = static_cast<int>(0.2125 * r + 0.7154 * g + 0.0721 * b);
|
||||
ASSERT_LE(std::abs(img.pixel(0) - expected), 1) << "r=" << r << " g=" << g << " b=" << b;
|
||||
}
|
||||
}
|
||||
}
|
||||
img.draw_pixel(1, 0, Color(255, 255, 255));
|
||||
EXPECT_EQ(img.pixel(1), 255);
|
||||
img.draw_pixel(2, 0, Color(0, 0, 0));
|
||||
EXPECT_EQ(img.pixel(2), 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::runtime_image::testing
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/components/runtime_image/jpeg_decoder.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
@@ -178,6 +179,7 @@ class SourceImage : public RuntimeImage {
|
||||
explicit SourceImage(image::ImageType type = image::IMAGE_TYPE_RGB, int width = 0, int height = 0)
|
||||
: RuntimeImage(JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {}
|
||||
size_t decoded_bytes() const { return this->decoded_bytes_; }
|
||||
int scale() const { return static_cast<const JpegDecoder *>(this->decoder_.get())->get_scale(); }
|
||||
};
|
||||
|
||||
/// The file in memory; fails reads once fail_at bytes have been read
|
||||
@@ -291,6 +293,23 @@ TEST(JpegSourceDecode, SteppedDirectRgb565MatchesFedDecode) {
|
||||
EXPECT_EQ(pixels(stepped), pixels(fed));
|
||||
}
|
||||
|
||||
// decodeBegin() is given the same reduced scale as decode(), and decodeRows() keeps it for every step
|
||||
TEST(JpegSourceDecode, SteppedScaledDecodeMatchesFedDecode) {
|
||||
for (auto type : {image::IMAGE_TYPE_RGB565, image::IMAGE_TYPE_RGB}) {
|
||||
// 24x20 is half of 48x40, so JPEGDEC decodes at 1/2 size
|
||||
SourceImage fed(type, 24, 20);
|
||||
ASSERT_TRUE(decode_fed(fed));
|
||||
ASSERT_EQ(fed.scale(), 2);
|
||||
|
||||
SourceImage stepped(type, 24, 20);
|
||||
MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS));
|
||||
int steps;
|
||||
ASSERT_EQ(decode_stepped(stepped, source, steps), DECODE_STEP_DONE);
|
||||
ASSERT_EQ(stepped.scale(), 2);
|
||||
EXPECT_EQ(pixels(stepped), pixels(fed)) << "image type " << type;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(JpegSourceDecode, WaitsForDataThatHasNotArrived) {
|
||||
SourceImage fed;
|
||||
ASSERT_TRUE(decode_fed(fed));
|
||||
|
||||
@@ -23,7 +23,7 @@ import asyncio
|
||||
from aioesphomeapi import LightInfo, SensorInfo, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -52,9 +52,11 @@ async def test_addressable_light_transition(
|
||||
return
|
||||
samples.append((loop.time(), state.state))
|
||||
|
||||
# Swallow the first state ESPHome sends per entity
|
||||
# InitialStateHelper swallows the first state ESPHome sends per entity
|
||||
# on subscribe, so on_state only sees real post-subscribe updates.
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start transition: off -> full white over 1 second. This is the
|
||||
# scenario from the bug report, compressed in time.
|
||||
|
||||
@@ -15,11 +15,7 @@ from aioesphomeapi import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, require_entity, require_only_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -103,11 +99,15 @@ async def test_alarm_control_panel_state_transitions(
|
||||
states_received.append(state.state)
|
||||
state_event.set()
|
||||
|
||||
# Use InitialStateHelper to handle initial state broadcast
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states from all entities
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Verify alarm panel started in DISARMED state
|
||||
initial_alarm_state = sub.initial_states.get(alarm_info.key)
|
||||
initial_alarm_state = initial_state_helper.initial_states.get(alarm_info.key)
|
||||
assert initial_alarm_state is not None, "No initial alarm state received"
|
||||
assert isinstance(initial_alarm_state, AlarmControlPanelEntityState)
|
||||
assert initial_alarm_state.state == AlarmControlPanelState.DISARMED
|
||||
@@ -193,7 +193,7 @@ async def test_alarm_control_panel_state_transitions(
|
||||
)
|
||||
await wait_for_state(AlarmControlPanelState.DISARMED)
|
||||
|
||||
# Verify basic state sequence (initial DISARMED is swallowed on subscribe)
|
||||
# Verify basic state sequence (initial DISARMED is handled by InitialStateHelper)
|
||||
expected_states = [
|
||||
AlarmControlPanelState.ARMING, # Arm away
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
|
||||
@@ -18,7 +18,7 @@ from .raw_api_client import (
|
||||
decode_fields,
|
||||
tag,
|
||||
)
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
COVER_COMMAND = MESSAGE_TYPE_OF[api_pb2.CoverCommandRequest]
|
||||
@@ -48,10 +48,12 @@ async def test_api_cover_toggle(
|
||||
plain_cover = require_entity(entities, "plain_cover", CoverInfo, exact=True)
|
||||
|
||||
positions: dict[int, list[float]] = {toggle_cover.key: [], plain_cover.key: []}
|
||||
waiter = StateWaiter()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, CoverState) and state.key in positions:
|
||||
positions[state.key].append(state.position)
|
||||
waiter.on_state(state)
|
||||
|
||||
def position_is(key: int, value: float) -> Callable[[EntityState], bool]:
|
||||
return lambda s: (
|
||||
@@ -60,8 +62,9 @@ async def test_api_cover_toggle(
|
||||
and s.position == pytest.approx(value, abs=0.01)
|
||||
)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
waiter = sub.waiter
|
||||
initial = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial.on_state_wrapper(on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
|
||||
# Capability discovery: only the cover with a toggle_action advertises toggle
|
||||
await raw.connect()
|
||||
|
||||
@@ -25,7 +25,7 @@ from .raw_api_client import (
|
||||
encode_varint,
|
||||
tag,
|
||||
)
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest]
|
||||
@@ -52,10 +52,12 @@ async def test_api_decode_wire_types(
|
||||
on, off = tag(2, WIRE_VARINT) + b"\x01", tag(2, WIRE_VARINT) + b"\x00"
|
||||
|
||||
switch_states: list[bool] = []
|
||||
waiter = StateWaiter()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SwitchState) and state.key == switch.key:
|
||||
switch_states.append(state.state)
|
||||
waiter.on_state(state)
|
||||
|
||||
def switch_is(value: bool) -> Callable[[EntityState], bool]:
|
||||
return lambda s: (
|
||||
@@ -67,8 +69,9 @@ async def test_api_decode_wire_types(
|
||||
isinstance(s, NumberState) and s.key == number.key and s.state == value
|
||||
)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
waiter = sub.waiter
|
||||
initial = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial.on_state_wrapper(on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
await raw.connect()
|
||||
|
||||
# A well formed command: fixed32 key, varint state
|
||||
|
||||
@@ -14,7 +14,7 @@ from aioesphomeapi import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
LONG_OPTION = (
|
||||
@@ -44,14 +44,16 @@ async def test_api_encode_boundaries(
|
||||
number = require_entity(entities, "negative_number")
|
||||
button = require_entity(entities, "publish_values")
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
waiter = sub.waiter
|
||||
initial = InitialStateHelper(entities)
|
||||
waiter = StateWaiter()
|
||||
client.subscribe_states(initial.on_state_wrapper(waiter.on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
|
||||
# A float of exactly zero is skipped on the wire and must still read as 0.0, not missing
|
||||
first = sub.initial_states[sensor.key]
|
||||
first = initial.initial_states[sensor.key]
|
||||
assert isinstance(first, SensorState)
|
||||
assert first.state == 0.0 and not first.missing_state
|
||||
first_number = sub.initial_states[number.key]
|
||||
first_number = initial.initial_states[number.key]
|
||||
assert isinstance(first_number, NumberState)
|
||||
assert first_number.state == -123.5
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ import asyncio
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
SensorStateCollector,
|
||||
assert_device_name,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
@@ -53,10 +53,13 @@ async def test_binary_sensor_autorepeat_filter(
|
||||
entities, "release", description="Release button"
|
||||
)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
autorepeat_states = collector.binary_states["autorepeat_sensor"]
|
||||
|
||||
# Press: source becomes true, autorepeat passes the initial true through
|
||||
|
||||
@@ -9,15 +9,14 @@ Regression test for: https://github.com/esphome/esphome/issues/12252
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
assert_device_name,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, assert_device_name, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -34,6 +33,19 @@ async def test_binary_sensor_invalidate_state(
|
||||
3. Publishing false sets missing_state=False and state=False
|
||||
4. Invalidating state sets missing_state=True (unknown state)
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track state changes
|
||||
states_received: list[BinarySensorState] = []
|
||||
state_future: asyncio.Future[BinarySensorState] = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
"""Track binary sensor state changes."""
|
||||
if isinstance(state, BinarySensorState):
|
||||
states_received.append(state)
|
||||
if not state_future.done():
|
||||
state_future.set_result(state)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
@@ -56,11 +68,15 @@ async def test_binary_sensor_invalidate_state(
|
||||
entities, "invalidate", description="Invalidate button"
|
||||
)
|
||||
|
||||
# Set up initial state helper to handle the initial state broadcast
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Check initial state - should be missing (unknown)
|
||||
initial_state = sub.initial_states.get(binary_sensor.key)
|
||||
initial_state = initial_state_helper.initial_states.get(binary_sensor.key)
|
||||
assert initial_state is not None, "No initial state received for binary sensor"
|
||||
assert isinstance(initial_state, BinarySensorState)
|
||||
assert initial_state.missing_state is True, (
|
||||
@@ -68,9 +84,11 @@ async def test_binary_sensor_invalidate_state(
|
||||
)
|
||||
|
||||
# Test 1: Set state to true
|
||||
next_state = sub.waiter.next_state(binary_sensor.key, BinarySensorState)
|
||||
states_received.clear()
|
||||
state_future = loop.create_future()
|
||||
client.button_command(set_true_button.key)
|
||||
state = await next_state
|
||||
|
||||
state = await wait_or_fail(state_future, 5.0, "Timeout waiting for state=true")
|
||||
|
||||
assert state.missing_state is False, (
|
||||
f"After setting true, missing_state should be False, got {state}"
|
||||
@@ -78,9 +96,11 @@ async def test_binary_sensor_invalidate_state(
|
||||
assert state.state is True, f"Expected state=True, got {state}"
|
||||
|
||||
# Test 2: Set state to false
|
||||
next_state = sub.waiter.next_state(binary_sensor.key, BinarySensorState)
|
||||
states_received.clear()
|
||||
state_future = loop.create_future()
|
||||
client.button_command(set_false_button.key)
|
||||
state = await next_state
|
||||
|
||||
state = await wait_or_fail(state_future, 5.0, "Timeout waiting for state=false")
|
||||
|
||||
assert state.missing_state is False, (
|
||||
f"After setting false, missing_state should be False, got {state}"
|
||||
@@ -89,10 +109,17 @@ async def test_binary_sensor_invalidate_state(
|
||||
|
||||
# Test 3: Invalidate state (set to unknown)
|
||||
# This is the critical test for the bug fix
|
||||
# A timeout here means invalidate_state() is not reported to the API (issue #12252)
|
||||
next_state = sub.waiter.next_state(binary_sensor.key, BinarySensorState)
|
||||
states_received.clear()
|
||||
state_future = loop.create_future()
|
||||
client.button_command(invalidate_button.key)
|
||||
state = await next_state
|
||||
|
||||
state = await wait_or_fail(
|
||||
state_future,
|
||||
5.0,
|
||||
"Timeout waiting for invalidated state - "
|
||||
"binary_sensor.invalidate_state() may not be reporting to the API. "
|
||||
"See issue #12252.",
|
||||
)
|
||||
|
||||
assert state.missing_state is True, (
|
||||
f"After invalidate_state(), missing_state should be True (unknown), "
|
||||
|
||||
@@ -7,12 +7,18 @@ multiple field combinations and the lambda path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import ClimateInfo, ClimateMode, ClimateState
|
||||
from aioesphomeapi import (
|
||||
ButtonInfo,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimateState,
|
||||
EntityState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -23,34 +29,56 @@ async def test_climate_control_action(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test climate ControlAction with constants and lambdas."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
climate_state_future: asyncio.Future[ClimateState] | None = None
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, ClimateState)
|
||||
and climate_state_future is not None
|
||||
and not climate_state_future.done()
|
||||
):
|
||||
climate_state_future.set_result(state)
|
||||
|
||||
async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState:
|
||||
nonlocal climate_state_future
|
||||
climate_state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(climate_state_future, timeout)
|
||||
finally:
|
||||
climate_state_future = None
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
require_entity(entities, "test_climate", ClimateInfo)
|
||||
|
||||
press = partial(
|
||||
press_and_wait, client, entities, sub.waiter, state_type=ClimateState
|
||||
)
|
||||
async def press_and_wait(name: str) -> ClimateState:
|
||||
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_climate_state()
|
||||
|
||||
# mode only — set HEAT
|
||||
state = await press("set_mode_heat")
|
||||
state = await press_and_wait("Set Mode Heat")
|
||||
assert state.mode == ClimateMode.HEAT
|
||||
|
||||
# mode + target_temperature_low + target_temperature_high
|
||||
state = await press("set_mode_temps")
|
||||
state = await press_and_wait("Set Mode Temps")
|
||||
assert state.mode == ClimateMode.HEAT_COOL
|
||||
assert state.target_temperature_low == pytest.approx(19.0, abs=0.5)
|
||||
assert state.target_temperature_high == pytest.approx(23.0, abs=0.5)
|
||||
|
||||
# target_temperature_low only
|
||||
state = await press("set_low_only")
|
||||
state = await press_and_wait("Set Low Only")
|
||||
assert state.target_temperature_low == pytest.approx(17.5, abs=0.5)
|
||||
|
||||
# lambda path: target_temperature_high computed at runtime
|
||||
state = await press("lambda_high")
|
||||
state = await press_and_wait("Lambda High")
|
||||
assert state.target_temperature_high == pytest.approx(21.5, abs=0.5)
|
||||
|
||||
# mode only — turn off via mode
|
||||
state = await press("set_off")
|
||||
state = await press_and_wait("Set Off")
|
||||
assert state.mode == ClimateMode.OFF
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import ClimateInfo, ClimatePreset
|
||||
from aioesphomeapi import ClimateInfo, ClimatePreset, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_only_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_only_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -17,13 +20,42 @@ async def test_climate_custom_fan_modes_and_presets(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that custom presets are properly exposed and can be changed."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
states: dict[int, EntityState] = {}
|
||||
super_saver_future: asyncio.Future[EntityState] = loop.create_future()
|
||||
vacation_future: asyncio.Future[EntityState] = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
states[state.key] = state
|
||||
if isinstance(state, aioesphomeapi.ClimateState):
|
||||
# Wait for Super Saver preset
|
||||
if (
|
||||
state.custom_preset == "Super Saver"
|
||||
and state.target_temperature_low == 20.0
|
||||
and state.target_temperature_high == 24.0
|
||||
and not super_saver_future.done()
|
||||
):
|
||||
super_saver_future.set_result(state)
|
||||
# Wait for Vacation Mode preset
|
||||
elif (
|
||||
state.custom_preset == "Vacation Mode"
|
||||
and state.target_temperature_low == 15.0
|
||||
and state.target_temperature_high == 18.0
|
||||
and not vacation_future.done()
|
||||
):
|
||||
vacation_future.set_result(state)
|
||||
|
||||
# Get entities and set up state synchronization
|
||||
entities, services = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
# Subscribe with the wrapper that filters initial states
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for all initial states to be broadcast
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Verify enum presets are exposed (from preset: config map)
|
||||
assert ClimatePreset.AWAY in test_climate.supported_presets, (
|
||||
@@ -44,7 +76,7 @@ async def test_climate_custom_fan_modes_and_presets(
|
||||
)
|
||||
|
||||
# Get initial state and verify default preset
|
||||
initial_state = sub.initial_states.get(test_climate.key)
|
||||
initial_state = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial_state is not None, "Climate initial state not found"
|
||||
assert isinstance(initial_state, aioesphomeapi.ClimateState)
|
||||
assert initial_state.custom_preset == "Eco Plus", (
|
||||
@@ -58,17 +90,13 @@ async def test_climate_custom_fan_modes_and_presets(
|
||||
)
|
||||
|
||||
# Test changing to "Super Saver" custom preset
|
||||
super_saver_seen = sub.waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, aioesphomeapi.ClimateState)
|
||||
and s.custom_preset == "Super Saver"
|
||||
and s.target_temperature_low == 20.0
|
||||
and s.target_temperature_high == 24.0
|
||||
),
|
||||
label="Super Saver preset change",
|
||||
)
|
||||
client.climate_command(test_climate.key, custom_preset="Super Saver")
|
||||
super_saver_state = await super_saver_seen
|
||||
|
||||
super_saver_state = await wait_or_fail(
|
||||
super_saver_future,
|
||||
5.0,
|
||||
"Super Saver preset change not received within 5 seconds",
|
||||
)
|
||||
|
||||
assert isinstance(super_saver_state, aioesphomeapi.ClimateState)
|
||||
assert super_saver_state.custom_preset == "Super Saver"
|
||||
@@ -76,17 +104,13 @@ async def test_climate_custom_fan_modes_and_presets(
|
||||
assert super_saver_state.target_temperature_high == 24.0
|
||||
|
||||
# Test changing to "Vacation Mode" custom preset
|
||||
vacation_seen = sub.waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, aioesphomeapi.ClimateState)
|
||||
and s.custom_preset == "Vacation Mode"
|
||||
and s.target_temperature_low == 15.0
|
||||
and s.target_temperature_high == 18.0
|
||||
),
|
||||
label="Vacation Mode preset change",
|
||||
)
|
||||
client.climate_command(test_climate.key, custom_preset="Vacation Mode")
|
||||
vacation_state = await vacation_seen
|
||||
|
||||
vacation_state = await wait_or_fail(
|
||||
vacation_future,
|
||||
5.0,
|
||||
"Vacation Mode preset change not received within 5 seconds",
|
||||
)
|
||||
|
||||
assert isinstance(vacation_state, aioesphomeapi.ClimateState)
|
||||
assert vacation_state.custom_preset == "Vacation Mode"
|
||||
|
||||
@@ -7,12 +7,12 @@ implementation. Exercises multiple field combinations and the lambda path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import CoverInfo, CoverState
|
||||
from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -23,58 +23,80 @@ async def test_cover_control_action(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test cover.control and cover.template.publish with constants and lambdas."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
cover_state_future: asyncio.Future[CoverState] | None = None
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, CoverState)
|
||||
and cover_state_future is not None
|
||||
and not cover_state_future.done()
|
||||
):
|
||||
cover_state_future.set_result(state)
|
||||
|
||||
async def wait_for_cover_state(timeout: float = 5.0) -> CoverState:
|
||||
nonlocal cover_state_future
|
||||
cover_state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(cover_state_future, timeout)
|
||||
finally:
|
||||
cover_state_future = None
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
require_entity(entities, "test_cover", CoverInfo)
|
||||
|
||||
press = partial(
|
||||
press_and_wait, client, entities, sub.waiter, state_type=CoverState
|
||||
)
|
||||
async def press_and_wait(name: str) -> CoverState:
|
||||
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_cover_state()
|
||||
|
||||
# cover.control: position only
|
||||
state = await press("set_position")
|
||||
state = await press_and_wait("Set Position")
|
||||
assert state.position == pytest.approx(0.5, abs=0.01)
|
||||
|
||||
# cover.control: tilt only
|
||||
state = await press("set_tilt")
|
||||
state = await press_and_wait("Set Tilt")
|
||||
assert state.tilt == pytest.approx(0.75, abs=0.01)
|
||||
|
||||
# cover.control: position + tilt
|
||||
state = await press("set_pos_tilt")
|
||||
state = await press_and_wait("Set Pos Tilt")
|
||||
assert state.position == pytest.approx(0.25, abs=0.01)
|
||||
assert state.tilt == pytest.approx(0.30, abs=0.01)
|
||||
|
||||
# cover.control: state alias for position 1.0
|
||||
state = await press("open_state")
|
||||
state = await press_and_wait("Open State")
|
||||
assert state.position == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
# cover.control: lambda position (test_position global = 0.42)
|
||||
state = await press("lambda_position")
|
||||
state = await press_and_wait("Lambda Position")
|
||||
assert state.position == pytest.approx(0.42, abs=0.01)
|
||||
|
||||
# cover.template.publish: position only
|
||||
state = await press("publish_pos")
|
||||
state = await press_and_wait("Publish Pos")
|
||||
assert state.position == pytest.approx(0.6, abs=0.01)
|
||||
|
||||
# cover.template.publish: current_operation only
|
||||
state = await press("publish_op")
|
||||
state = await press_and_wait("Publish Op")
|
||||
# CoverOperation.OPENING == 1
|
||||
assert state.current_operation == 1
|
||||
|
||||
# cover.control: stop only — template cover's stop_action publishes
|
||||
# current_operation: IDLE.
|
||||
state = await press("stop_cover")
|
||||
state = await press_and_wait("Stop Cover")
|
||||
# CoverOperation.IDLE == 0
|
||||
assert state.current_operation == 0
|
||||
|
||||
# stop_tilt_action publishes tilt 0.4
|
||||
state = await press("stop_tilt")
|
||||
state = await press_and_wait("Stop Tilt")
|
||||
assert state.tilt == pytest.approx(0.4, abs=0.01)
|
||||
|
||||
state = await press("set_tilt")
|
||||
state = await press_and_wait("Set Tilt")
|
||||
assert state.tilt == pytest.approx(0.75, abs=0.01)
|
||||
|
||||
state = await press("control_stop_tilt")
|
||||
state = await press_and_wait("Control Stop Tilt")
|
||||
assert state.tilt == pytest.approx(0.4, abs=0.01)
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from aioesphomeapi import ButtonInfo, CoverInfo, CoverState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ async def test_cover_stop_tilt_cancel(
|
||||
tilts.append(state.tilt)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
cover = require_entity(entities, "test_cover", CoverInfo)
|
||||
button = require_entity(entities, "stop_tilt", ButtonInfo)
|
||||
|
||||
@@ -15,6 +15,7 @@ from aioesphomeapi import (
|
||||
ClimateMode,
|
||||
ClimateState,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
SwitchInfo,
|
||||
SwitchState,
|
||||
WaterHeaterInfo,
|
||||
@@ -23,8 +24,9 @@ from aioesphomeapi import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -40,6 +42,15 @@ async def test_entity_missing_state(
|
||||
2. Publishing clears missing_state and reports the published value
|
||||
3. A first value that happens to equal the default still publishes
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
futures: dict[int, asyncio.Future[EntityState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
"""Resolve the pending future for the entity that changed."""
|
||||
future = futures.get(state.key)
|
||||
if future is not None and not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
@@ -65,11 +76,14 @@ async def test_entity_missing_state(
|
||||
lambda_water_heater_info,
|
||||
]
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Nothing has published yet, so all of them must report unknown
|
||||
for info in stateful:
|
||||
state = sub.initial_states.get(info.key)
|
||||
state = initial_state_helper.initial_states.get(info.key)
|
||||
assert state is not None, f"No initial state received for {info.object_id}"
|
||||
assert state.missing_state is True, (
|
||||
f"Initial state for {info.object_id} should have "
|
||||
@@ -77,34 +91,41 @@ async def test_entity_missing_state(
|
||||
)
|
||||
|
||||
# A configured initial_state must not start out unknown
|
||||
initial_climate_state = sub.initial_states.get(initial_climate_info.key)
|
||||
initial_climate_state = initial_state_helper.initial_states.get(
|
||||
initial_climate_info.key
|
||||
)
|
||||
assert isinstance(initial_climate_state, ClimateState)
|
||||
assert initial_climate_state.missing_state is False
|
||||
assert initial_climate_state.mode is ClimateMode.HEAT
|
||||
|
||||
# Publishing a state on each one clears missing_state
|
||||
waits = {info.key: sub.waiter.next_state(info.key) for info in stateful}
|
||||
futures = {info.key: loop.create_future() for info in stateful}
|
||||
client.button_command(publish_button.key)
|
||||
published = dict(zip(waits, await asyncio.gather(*waits.values()), strict=True))
|
||||
|
||||
switch_state = published[switch_info.key]
|
||||
await wait_or_fail(
|
||||
asyncio.gather(*futures.values()),
|
||||
5.0,
|
||||
"Timeout waiting for published states",
|
||||
)
|
||||
|
||||
switch_state = futures[switch_info.key].result()
|
||||
assert isinstance(switch_state, SwitchState)
|
||||
assert switch_state.missing_state is False
|
||||
assert switch_state.state is True
|
||||
|
||||
climate_state = published[climate_info.key]
|
||||
climate_state = futures[climate_info.key].result()
|
||||
assert isinstance(climate_state, ClimateState)
|
||||
assert climate_state.missing_state is False
|
||||
assert climate_state.mode is ClimateMode.HEAT
|
||||
|
||||
water_heater_state = published[water_heater_info.key]
|
||||
water_heater_state = futures[water_heater_info.key].result()
|
||||
assert isinstance(water_heater_state, WaterHeaterState)
|
||||
assert water_heater_state.missing_state is False
|
||||
assert water_heater_state.mode is WaterHeaterMode.ECO
|
||||
|
||||
# Its first value is OFF, which is also the default the entity starts on,
|
||||
# so it only leaves unknown if the first value publishes regardless
|
||||
lambda_state = published[lambda_water_heater_info.key]
|
||||
lambda_state = futures[lambda_water_heater_info.key].result()
|
||||
assert isinstance(lambda_state, WaterHeaterState)
|
||||
assert lambda_state.missing_state is False
|
||||
assert lambda_state.mode is WaterHeaterMode.OFF
|
||||
|
||||
@@ -6,12 +6,12 @@ field combinations and the lambda path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import FanDirection, FanInfo, FanState
|
||||
from aioesphomeapi import ButtonInfo, EntityState, FanDirection, FanInfo, FanState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -22,32 +22,54 @@ async def test_fan_turn_on_action(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test fan TurnOnAction with constants and a lambda."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
fan_state_future: asyncio.Future[FanState] | None = None
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, FanState)
|
||||
and fan_state_future is not None
|
||||
and not fan_state_future.done()
|
||||
):
|
||||
fan_state_future.set_result(state)
|
||||
|
||||
async def wait_for_fan_state(timeout: float = 5.0) -> FanState:
|
||||
nonlocal fan_state_future
|
||||
fan_state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(fan_state_future, timeout)
|
||||
finally:
|
||||
fan_state_future = None
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
require_entity(entities, "test_fan", FanInfo)
|
||||
|
||||
press = partial(
|
||||
press_and_wait, client, entities, sub.waiter, state_type=FanState
|
||||
)
|
||||
async def press_and_wait(name: str) -> FanState:
|
||||
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_fan_state()
|
||||
|
||||
# speed only
|
||||
state = await press("set_speed")
|
||||
state = await press_and_wait("Set Speed")
|
||||
assert state.state is True
|
||||
assert state.speed_level == 3
|
||||
|
||||
# oscillating + direction
|
||||
state = await press("set_oscillate_direction")
|
||||
state = await press_and_wait("Set Oscillate Direction")
|
||||
assert state.oscillating is True
|
||||
assert state.direction == FanDirection.REVERSE
|
||||
|
||||
# all three fields
|
||||
state = await press("set_all_fields")
|
||||
state = await press_and_wait("Set All Fields")
|
||||
assert state.oscillating is False
|
||||
assert state.speed_level == 4
|
||||
assert state.direction == FanDirection.FORWARD
|
||||
|
||||
# lambda path: speed computed at runtime (test_speed global = 2)
|
||||
state = await press("lambda_speed")
|
||||
state = await press_and_wait("Lambda Speed")
|
||||
assert state.speed_level == 2
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi
|
||||
from aioesphomeapi import ClimateInfo, ClimateMode
|
||||
from aioesphomeapi import ClimateInfo, ClimateMode, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_only_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_only_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -17,25 +20,35 @@ async def test_host_mode_climate_control(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test climate mode control."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
states: dict[int, EntityState] = {}
|
||||
climate_future: asyncio.Future[EntityState] = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
states[state.key] = state
|
||||
if (
|
||||
isinstance(state, aioesphomeapi.ClimateState)
|
||||
and state.mode == ClimateMode.HEAT
|
||||
and state.target_temperature_low == 21.5
|
||||
and state.target_temperature_high == 26.5
|
||||
and not climate_future.done()
|
||||
):
|
||||
climate_future.set_result(state)
|
||||
|
||||
# Get entities and set up state synchronization
|
||||
entities, services = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
assert test_climate.name == "Dual-mode Thermostat"
|
||||
|
||||
# Subscribe with the wrapper that filters initial states
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for all initial states to be broadcast
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Adjust setpoints
|
||||
climate_seen = sub.waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, aioesphomeapi.ClimateState)
|
||||
and s.mode == ClimateMode.HEAT
|
||||
and s.target_temperature_low == 21.5
|
||||
and s.target_temperature_high == 26.5
|
||||
),
|
||||
label="Climate state",
|
||||
)
|
||||
client.climate_command(
|
||||
test_climate.key,
|
||||
mode=ClimateMode.HEAT,
|
||||
@@ -43,7 +56,9 @@ async def test_host_mode_climate_control(
|
||||
target_temperature_high=26.5,
|
||||
)
|
||||
|
||||
climate_state = await climate_seen
|
||||
climate_state = await wait_or_fail(
|
||||
climate_future, 5.0, "Climate state not received within 5 seconds"
|
||||
)
|
||||
|
||||
assert isinstance(climate_state, aioesphomeapi.ClimateState)
|
||||
assert climate_state.mode == ClimateMode.HEAT
|
||||
|
||||
@@ -23,10 +23,10 @@ import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import LightState
|
||||
from aioesphomeapi import EntityState, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)")
|
||||
@@ -66,15 +66,25 @@ async def test_light_binary_effect_off_phase(
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_binary_light", exact=True)
|
||||
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
# ESPHome sends the current state of every entity right after connecting; drain
|
||||
# that initial burst so it can't be mistaken for the response to a command below.
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(**kwargs: Any) -> LightState:
|
||||
"""Send a light command and return the light's next state."""
|
||||
next_light = sub.waiter.next_state(light.key, LightState)
|
||||
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
|
||||
"""Send a light command and wait for the matching state response."""
|
||||
state_futures[light.key] = loop.create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await next_light
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
# A plain turn-on must drive the output on -- brightness defaults to 100% and
|
||||
# must not be mistaken for a dark phase.
|
||||
@@ -155,13 +165,22 @@ async def test_light_binary_zero_brightness_is_recoverable(
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_binary_light", exact=True)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
async def send_and_wait(**kwargs: Any) -> LightState:
|
||||
"""Send a light command and return the light's next state."""
|
||||
next_light = sub.waiter.next_state(light.key, LightState)
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
|
||||
state_futures[light.key] = loop.create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await next_light
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
output = arm_output()
|
||||
state = await send_and_wait(state=True)
|
||||
|
||||
@@ -12,15 +12,17 @@ gamma after the balancing formula instead of before it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import LightInfo, LightState
|
||||
from aioesphomeapi import EntityState, LightInfo, LightState
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
CB_CW_RE = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
|
||||
CB_WW_RE = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
|
||||
@@ -44,6 +46,8 @@ async def test_light_constant_brightness(
|
||||
return float(match[1])
|
||||
return 0.0
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
@@ -52,13 +56,33 @@ async def test_light_constant_brightness(
|
||||
cb_light = require_entity(entities, "test_cb_light", LightInfo, exact=True)
|
||||
ncb_light = require_entity(entities, "test_ncb_light", LightInfo, exact=True)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
# Use InitialStateHelper to wait for initial state broadcast
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
async def send_and_wait(light_key: int, **kwargs: Any) -> LightState:
|
||||
"""Send a light command and return the light's next state."""
|
||||
next_light = sub.waiter.next_state(light_key, LightState)
|
||||
# Track state changes per light key
|
||||
state_futures: dict[int, asyncio.Future[EntityState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(
|
||||
light_key: int, timeout: float = 5.0, **kwargs: Any
|
||||
) -> LightState:
|
||||
"""Send a light command and wait for the state response."""
|
||||
state_futures[light_key] = loop.create_future()
|
||||
client.light_command(key=light_key, **kwargs)
|
||||
return await next_light
|
||||
return await wait_or_fail(
|
||||
state_futures[light_key],
|
||||
timeout,
|
||||
f"Timeout waiting for light state after command: {kwargs}",
|
||||
)
|
||||
|
||||
# --- Test constant_brightness: true ---
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ work correctly with the compact per-field union storage. Exercises both constant
|
||||
value and lambda paths.
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -22,20 +22,36 @@ async def test_light_control_action(
|
||||
) -> None:
|
||||
"""Test light.turn_on, light.turn_off and light.control with constants and lambdas."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_light", exact=True)
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
press = partial(
|
||||
press_and_wait,
|
||||
client,
|
||||
entities,
|
||||
sub.waiter,
|
||||
state_type=LightState,
|
||||
key=light.key,
|
||||
)
|
||||
state_futures: dict[int, asyncio.Future[Any]] = {}
|
||||
|
||||
def on_state(state: Any) -> None:
|
||||
if state.key in state_futures and not state_futures[state.key].done():
|
||||
state_futures[state.key].set_result(state)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
# Get entities
|
||||
entities = await client.list_entities_services()
|
||||
light = require_entity(entities[0], "test_light", exact=True)
|
||||
buttons = {e.name: e for e in entities[0] if hasattr(e, "name")}
|
||||
|
||||
async def wait_for_state(key: int, timeout: float = 5.0) -> Any:
|
||||
"""Wait for a state change for the given entity key."""
|
||||
loop = asyncio.get_running_loop()
|
||||
state_futures[key] = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(state_futures[key], timeout)
|
||||
finally:
|
||||
state_futures.pop(key, None)
|
||||
|
||||
async def press_and_wait(button_name: str) -> Any:
|
||||
"""Press a button and wait for light state change."""
|
||||
btn = buttons[button_name]
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_state(light.key)
|
||||
|
||||
# Test 1: light.turn_on with RGB constants
|
||||
state = await press("turn_on_rgb")
|
||||
state = await press_and_wait("Turn On RGB")
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(1.0)
|
||||
assert state.red == pytest.approx(0.0, abs=0.01)
|
||||
@@ -43,31 +59,31 @@ async def test_light_control_action(
|
||||
assert state.blue == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
# Test 2: light.turn_off
|
||||
state = await press("turn_off")
|
||||
state = await press_and_wait("Turn Off")
|
||||
assert state.state is False
|
||||
|
||||
# Test 3: light.turn_on with color_temperature
|
||||
state = await press("turn_on_ct")
|
||||
state = await press_and_wait("Turn On CT")
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.8)
|
||||
assert state.color_temperature == pytest.approx(250.0) # 4000K = 250 mireds
|
||||
|
||||
# Test 4: light.turn_on with effect
|
||||
state = await press("turn_on_effect")
|
||||
state = await press_and_wait("Turn On Effect")
|
||||
assert state.effect == "Test Effect"
|
||||
|
||||
# Test 5: Clear effect
|
||||
state = await press("clear_effect")
|
||||
state = await press_and_wait("Clear Effect")
|
||||
assert state.effect == "None"
|
||||
|
||||
# Test 6: light.control with cold/warm white
|
||||
state = await press("control_cw")
|
||||
state = await press_and_wait("Control CW")
|
||||
assert state.cold_white == pytest.approx(0.9, abs=0.1)
|
||||
assert state.warm_white == pytest.approx(0.1, abs=0.1)
|
||||
|
||||
# Test 7: light.turn_on with lambda brightness
|
||||
# The global test_brightness is 0.75
|
||||
state = await press("lambda_brightness")
|
||||
state = await press_and_wait("Lambda Brightness")
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.75, abs=0.05)
|
||||
assert state.red == pytest.approx(1.0, abs=0.01)
|
||||
@@ -75,6 +91,6 @@ async def test_light_control_action(
|
||||
assert state.blue == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
# Test 8: light.turn_on with transition_length and brightness
|
||||
state = await press("turn_on_transition")
|
||||
state = await press_and_wait("Turn On Transition")
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.5)
|
||||
|
||||
@@ -6,12 +6,12 @@ DimRelativeAction<HasTransitionLength=true> instantiations.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import LightInfo, LightState
|
||||
from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -22,29 +22,51 @@ async def test_light_dim_relative_action(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test light.dim_relative with and without transition_length."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
light_state_future: asyncio.Future[LightState] | None = None
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, LightState)
|
||||
and light_state_future is not None
|
||||
and not light_state_future.done()
|
||||
):
|
||||
light_state_future.set_result(state)
|
||||
|
||||
async def wait_for_light_state(timeout: float = 5.0) -> LightState:
|
||||
nonlocal light_state_future
|
||||
light_state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(light_state_future, timeout)
|
||||
finally:
|
||||
light_state_future = None
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
require_entity(entities, "test_light", LightInfo)
|
||||
|
||||
press = partial(
|
||||
press_and_wait, client, entities, sub.waiter, state_type=LightState
|
||||
)
|
||||
async def press_and_wait(name: str) -> LightState:
|
||||
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_light_state()
|
||||
|
||||
# Setup: turn on at 50%
|
||||
state = await press("setup")
|
||||
state = await press_and_wait("Setup")
|
||||
assert state.state is True
|
||||
assert state.brightness == pytest.approx(0.5, abs=0.05)
|
||||
|
||||
# Test 1: dim_relative without transition_length: 50% + 25% = 75%
|
||||
state = await press("dim_up")
|
||||
state = await press_and_wait("Dim Up")
|
||||
assert state.brightness == pytest.approx(0.75, abs=0.05)
|
||||
|
||||
# Test 2: dim_relative with transition_length: 75% - 10% = 65%
|
||||
state = await press("dim_down")
|
||||
state = await press_and_wait("Dim Down")
|
||||
assert state.brightness == pytest.approx(0.65, abs=0.05)
|
||||
|
||||
# Test 3: dim_relative with max_brightness limit: 65% + 50% clamped to 80%
|
||||
state = await press("dim_clamp")
|
||||
state = await press_and_wait("Dim Clamp")
|
||||
assert state.brightness == pytest.approx(0.80, abs=0.05)
|
||||
|
||||
@@ -17,10 +17,10 @@ import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import LightState
|
||||
from aioesphomeapi import EntityState, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -46,15 +46,25 @@ async def test_light_effect_zero_brightness(
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_pulse_light", exact=True)
|
||||
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
# ESPHome sends the current state of every entity right after connecting; drain
|
||||
# that initial burst so it can't be mistaken for the response to a command below.
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(**kwargs: Any) -> LightState:
|
||||
"""Send a light command and return the light's next state."""
|
||||
next_light = sub.waiter.next_state(light.key, LightState)
|
||||
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
|
||||
"""Send a light command and wait for the matching state response."""
|
||||
state_futures[light.key] = asyncio.get_running_loop().create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await next_light
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
# Turn the light on first so the effect starts from a known, visible state.
|
||||
state = await send_and_wait(state=True, brightness=1.0)
|
||||
|
||||
@@ -7,7 +7,7 @@ that the restore logic uses as a fallback.
|
||||
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ async def test_light_initial_state(
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_light")
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(helper.on_state_wrapper(lambda s: None))
|
||||
await helper.wait_for_initial_states()
|
||||
|
||||
state = sub.initial_states[light.key]
|
||||
state = helper.initial_states[light.key]
|
||||
|
||||
# restore_mode: ALWAYS_OFF overrides state to false
|
||||
assert state.state is False
|
||||
@@ -38,14 +40,14 @@ async def test_light_initial_state(
|
||||
# Regression test: RESTORE_AND_ON always forces the light on at boot, even when
|
||||
# the recovered/initial brightness was 0 -- it must never come up on-but-invisible.
|
||||
restore_and_on_light = require_entity(entities, "test_restore_and_on_light")
|
||||
restore_and_on_state = sub.initial_states[restore_and_on_light.key]
|
||||
restore_and_on_state = helper.initial_states[restore_and_on_light.key]
|
||||
assert restore_and_on_state.state is True
|
||||
assert restore_and_on_state.brightness == pytest.approx(1.0)
|
||||
|
||||
# initial_state may also name the effect to start with. The light must come up
|
||||
# with that effect already active, without an on_boot automation.
|
||||
effect_light = require_entity(entities, "test_effect_light")
|
||||
effect_state = sub.initial_states[effect_light.key]
|
||||
effect_state = helper.initial_states[effect_light.key]
|
||||
assert effect_state.state is True
|
||||
assert effect_state.effect == "Test Pulse"
|
||||
|
||||
@@ -53,7 +55,7 @@ async def test_light_initial_state(
|
||||
# be honored as-is -- unlike every explicit restore_mode: value, omitting both
|
||||
# keys entirely must not force the light off.
|
||||
no_restore_key_light = require_entity(entities, "test_no_restore_key_light")
|
||||
no_restore_key_state = sub.initial_states[no_restore_key_light.key]
|
||||
no_restore_key_state = helper.initial_states[no_restore_key_light.key]
|
||||
assert no_restore_key_state.state is True
|
||||
assert no_restore_key_state.brightness == pytest.approx(0.6, abs=0.01)
|
||||
|
||||
@@ -61,7 +63,7 @@ async def test_light_initial_state(
|
||||
# light comes up red rather than the default white). RGB values are normalised to
|
||||
# proportions, so 30% red on its own is pure red.
|
||||
inferred_light = require_entity(entities, "test_inferred_color_mode_light")
|
||||
inferred_state = sub.initial_states[inferred_light.key]
|
||||
inferred_state = helper.initial_states[inferred_light.key]
|
||||
assert inferred_state.state is True
|
||||
assert inferred_state.red == pytest.approx(1.0, abs=0.01)
|
||||
assert inferred_state.green == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
@@ -14,10 +14,10 @@ import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import LightState
|
||||
from aioesphomeapi import EntityState, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -47,13 +47,23 @@ async def test_light_repeat_effect(
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_rgb_light", exact=True)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
async def send_and_wait(**kwargs: Any) -> LightState:
|
||||
"""Send a light command and return the light's next state."""
|
||||
next_light = sub.waiter.next_state(light.key, LightState)
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
|
||||
"""Send a light command and wait for the matching state response."""
|
||||
state_futures[light.key] = asyncio.get_running_loop().create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await next_light
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
# Plain turn-on leaves the published colour at the white default
|
||||
state = await send_and_wait(state=True, transition_length=0.0)
|
||||
|
||||
@@ -10,14 +10,16 @@ Tests that:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import LightInfo, LightState
|
||||
import pytest
|
||||
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-light-restore-state-test"
|
||||
@@ -52,26 +54,38 @@ async def test_light_restore_state(
|
||||
# A single subscription serves both initial-state capture and later
|
||||
# state-change waits -- a second subscribe_states call would restart the
|
||||
# device's initial-state iterator and could resolve a wait on replayed data.
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
loop = asyncio.get_running_loop()
|
||||
state_futures: dict[int, asyncio.Future[Any]] = {}
|
||||
|
||||
keep_initial = sub.initial_states[keep_light.key]
|
||||
def on_state(state: Any) -> None:
|
||||
if state.key in state_futures and not state_futures[state.key].done():
|
||||
state_futures[state.key].set_result(state)
|
||||
|
||||
helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(helper.on_state_wrapper(on_state))
|
||||
await helper.wait_for_initial_states()
|
||||
|
||||
keep_initial = helper.initial_states[keep_light.key]
|
||||
assert isinstance(keep_initial, LightState)
|
||||
assert keep_initial.state is False
|
||||
|
||||
override_initial = sub.initial_states[override_light.key]
|
||||
override_initial = helper.initial_states[override_light.key]
|
||||
assert isinstance(override_initial, LightState)
|
||||
assert override_initial.state is False
|
||||
|
||||
# Set both lights to a known, saved state
|
||||
next_keep = sub.waiter.next_state(keep_light.key, LightState, timeout=2.0)
|
||||
async def wait_for_state(key: int, timeout: float = 2.0) -> Any:
|
||||
state_futures[key] = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(state_futures[key], timeout)
|
||||
finally:
|
||||
state_futures.pop(key, None)
|
||||
|
||||
client.light_command(key=keep_light.key, state=True, brightness=0.64)
|
||||
await next_keep
|
||||
await wait_for_state(keep_light.key)
|
||||
|
||||
next_override = sub.waiter.next_state(
|
||||
override_light.key, LightState, timeout=2.0
|
||||
)
|
||||
client.light_command(key=override_light.key, state=True, brightness=0.3)
|
||||
await next_override
|
||||
await wait_for_state(override_light.key)
|
||||
|
||||
# --- Second run: same binary, same prefs file ---
|
||||
async with (
|
||||
@@ -82,17 +96,19 @@ async def test_light_restore_state(
|
||||
keep_light = require_entity(entities, "test_light_keep", LightInfo)
|
||||
override_light = require_entity(entities, "test_light_override", LightInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(helper.on_state_wrapper(lambda s: None))
|
||||
await helper.wait_for_initial_states()
|
||||
|
||||
# restore_state: {} -- comes back exactly as saved
|
||||
keep_state = sub.initial_states[keep_light.key]
|
||||
keep_state = helper.initial_states[keep_light.key]
|
||||
assert isinstance(keep_state, LightState)
|
||||
assert keep_state.state is True
|
||||
assert keep_state.brightness == pytest.approx(0.64, abs=0.01)
|
||||
|
||||
# restore_state: with overrides -- state inverted, brightness forced to 100%
|
||||
# regardless of what was actually saved (0.3)
|
||||
override_state = sub.initial_states[override_light.key]
|
||||
override_state = helper.initial_states[override_light.key]
|
||||
assert isinstance(override_state, LightState)
|
||||
assert override_state.state is False
|
||||
assert override_state.brightness == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
@@ -7,12 +7,13 @@ parameters.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import ColorMode, LightInfo, LightState
|
||||
from aioesphomeapi import ColorMode, EntityState, LightInfo, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -31,14 +32,26 @@ async def test_light_resume_effect(
|
||||
light_resume = require_entity(entities, "test_light_resume", exact=True)
|
||||
light_no_resume = require_entity(entities, "test_light_no_resume", exact=True)
|
||||
|
||||
# Drain initial state burst
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
state_futures: dict[int, asyncio.Future[LightState]] = {}
|
||||
|
||||
async def send_and_wait(light: LightInfo, **kwargs: Any) -> LightState:
|
||||
"""Send a light command and return the light's next state."""
|
||||
next_light = sub.waiter.next_state(light.key, LightState)
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
# Drain initial state burst
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def send_and_wait(
|
||||
light: LightInfo, timeout: float = 5.0, **kwargs: Any
|
||||
) -> LightState:
|
||||
"""Send a light command and wait for the matching state response."""
|
||||
state_futures[light.key] = asyncio.get_running_loop().create_future()
|
||||
client.light_command(key=light.key, **kwargs)
|
||||
return await next_light
|
||||
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
|
||||
|
||||
# Test 1: Start with Pulse effect
|
||||
state = await send_and_wait(light_resume, state=True, effect="Pulse Effect")
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import LightInfo, LightState
|
||||
from aioesphomeapi import ButtonInfo, EntityState, LightInfo, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -18,24 +18,46 @@ async def test_light_toggle_action(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test light.toggle with and without transition_length."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
light_state_future: asyncio.Future[LightState] | None = None
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, LightState)
|
||||
and light_state_future is not None
|
||||
and not light_state_future.done()
|
||||
):
|
||||
light_state_future.set_result(state)
|
||||
|
||||
async def wait_for_light_state(timeout: float = 5.0) -> LightState:
|
||||
nonlocal light_state_future
|
||||
light_state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(light_state_future, timeout)
|
||||
finally:
|
||||
light_state_future = None
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
require_entity(entities, "test_light", LightInfo)
|
||||
|
||||
press = partial(
|
||||
press_and_wait, client, entities, sub.waiter, state_type=LightState
|
||||
)
|
||||
async def press_and_wait(name: str) -> LightState:
|
||||
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_light_state()
|
||||
|
||||
# Test 1: toggle without transition_length flips off->on
|
||||
state = await press("toggle")
|
||||
state = await press_and_wait("Toggle")
|
||||
assert state.state is True
|
||||
|
||||
# Test 2: toggle with transition_length flips on->off
|
||||
state = await press("toggle_with_trans")
|
||||
state = await press_and_wait("Toggle With Trans")
|
||||
assert state.state is False
|
||||
|
||||
# Test 3: toggle without transition_length flips off->on again
|
||||
state = await press("toggle")
|
||||
state = await press_and_wait("Toggle")
|
||||
assert state.state is True
|
||||
|
||||
@@ -16,7 +16,7 @@ from aioesphomeapi import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states, wait_for_state
|
||||
from .state_utils import InitialStateHelper, require_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
Timeline = list[tuple[float, LightState]]
|
||||
@@ -75,8 +75,10 @@ class _Recorder:
|
||||
async def _subscribe(client: APIClient) -> tuple[list[EntityInfo], _Recorder]:
|
||||
"""List entities and attach a recorder once the initial states have arrived."""
|
||||
entities, _ = await client.list_entities_services()
|
||||
helper = InitialStateHelper(entities)
|
||||
recorder = _Recorder()
|
||||
await subscribe_after_initial_states(client, entities, on_state=recorder.on_state)
|
||||
client.subscribe_states(helper.on_state_wrapper(recorder.on_state))
|
||||
await helper.wait_for_initial_states()
|
||||
return entities, recorder
|
||||
|
||||
|
||||
|
||||
@@ -30,11 +30,7 @@ from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
|
||||
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs, write_host_prefs
|
||||
from .state_utils import (
|
||||
assert_device_name,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, assert_device_name, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-pref-key-stability"
|
||||
@@ -106,11 +102,15 @@ async def test_preference_key_stability(
|
||||
)
|
||||
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda s: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
switch_state = sub.initial_states[switch_entity.key]
|
||||
number_state = sub.initial_states[number_entity.key]
|
||||
text_state = sub.initial_states[text_entity.key]
|
||||
switch_state = initial_state_helper.initial_states[switch_entity.key]
|
||||
number_state = initial_state_helper.initial_states[number_entity.key]
|
||||
text_state = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(switch_state, SwitchState)
|
||||
assert isinstance(number_state, NumberState)
|
||||
assert isinstance(text_state, TextState)
|
||||
|
||||
@@ -8,7 +8,7 @@ import math
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import build_key_to_entity_mapping, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -109,8 +109,14 @@ async def test_sensor_filters_delta(
|
||||
},
|
||||
)
|
||||
|
||||
# Set up initial state helper with all entities
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Subscribe to state changes with wrapper
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Find all buttons
|
||||
button_name_map = {
|
||||
|
||||
@@ -5,11 +5,7 @@ from __future__ import annotations
|
||||
from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -33,9 +29,11 @@ async def test_sensor_filters_rh_correction(
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
done = collector.add_waiter(
|
||||
lambda: all(
|
||||
|
||||
@@ -7,11 +7,7 @@ import asyncio
|
||||
from aioesphomeapi import EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
build_key_to_entity_mapping,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -81,8 +77,14 @@ async def test_sensor_filters_ring_buffer(
|
||||
],
|
||||
)
|
||||
|
||||
# Set up initial state helper with all entities
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Subscribe to state changes with wrapper
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states to be sent before pressing button
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Find the publish button
|
||||
publish_button = require_entity(entities, "publish_values_button")
|
||||
|
||||
@@ -7,11 +7,7 @@ import asyncio
|
||||
from aioesphomeapi import EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
build_key_to_entity_mapping,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -113,8 +109,14 @@ async def test_sensor_filters_sliding_window(
|
||||
],
|
||||
)
|
||||
|
||||
# Set up initial state helper with all entities
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Subscribe to state changes with wrapper
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states to be sent before pressing button
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Find the publish button
|
||||
publish_button = require_entity(entities, "publish_values_button")
|
||||
@@ -229,8 +231,14 @@ async def test_sensor_filters_nan_handling(
|
||||
# Build key-to-sensor mapping
|
||||
key_to_sensor = build_key_to_entity_mapping(entities, ["min_nan", "max_nan"])
|
||||
|
||||
# Set up initial state helper with all entities
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Subscribe to state changes with wrapper
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Find the publish button
|
||||
publish_button = require_entity(entities, "publish_nan_values_button")
|
||||
@@ -315,8 +323,14 @@ async def test_sensor_filters_ring_buffer_wraparound(
|
||||
# Build key-to-sensor mapping
|
||||
key_to_sensor = build_key_to_entity_mapping(entities, ["wraparound_min"])
|
||||
|
||||
# Set up initial state helper with all entities
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Subscribe to state changes with wrapper
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial state
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Find the publish button
|
||||
publish_button = require_entity(entities, "publish_wraparound_button")
|
||||
|
||||
@@ -8,7 +8,7 @@ import math
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import build_key_to_entity_mapping, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -116,8 +116,14 @@ async def test_sensor_filters_value_list(
|
||||
},
|
||||
)
|
||||
|
||||
# Set up initial state helper with all entities
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Subscribe to state changes with wrapper
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Find all buttons
|
||||
button_name_map = {
|
||||
|
||||
@@ -7,11 +7,7 @@ import asyncio
|
||||
from aioesphomeapi import EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
build_key_to_entity_mapping,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -91,7 +87,10 @@ async def test_sensor_timeout_filter(
|
||||
],
|
||||
)
|
||||
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Helper to find buttons by object_id substring
|
||||
def find_button(object_id_substring: str) -> int:
|
||||
|
||||
@@ -8,11 +8,7 @@ import math
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
build_key_to_entity_mapping,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -43,7 +39,9 @@ async def test_sensor_timeout_throttle_filter(
|
||||
key_to_sensor = build_key_to_entity_mapping(
|
||||
entities, {"merged": "Merged", "reference": "Reference"}
|
||||
)
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
button = require_entity(entities, "drive", ButtonInfo, exact=True)
|
||||
client.button_command(button.key)
|
||||
|
||||
@@ -19,11 +19,7 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
SensorTracker,
|
||||
build_key_to_entity_mapping,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Time to let the host-mode main loop run so status_led_light.loop() can
|
||||
@@ -73,9 +69,9 @@ async def test_status_flags(
|
||||
|
||||
# Swallow initial state broadcasts so the test only reacts to
|
||||
# state changes triggered by our service calls.
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=tracker.on_state
|
||||
)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
async def call(name: str) -> None:
|
||||
await client.execute_service(svc[name], {})
|
||||
|
||||
@@ -8,7 +8,7 @@ from aioesphomeapi import BinarySensorState, EntityState
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -37,9 +37,11 @@ async def test_status_momentary(
|
||||
if isinstance(state, BinarySensorState):
|
||||
changes.put_nowait((state.key, state.state, loop.time()))
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
assert sub.initial_states[warning_key].state is False
|
||||
assert sub.initial_states[error_key].state is False
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
assert initial_state_helper.initial_states[warning_key].state is False
|
||||
assert initial_state_helper.initial_states[error_key].state is False
|
||||
|
||||
async def next_change(key: int) -> tuple[bool, float]:
|
||||
while True:
|
||||
|
||||
@@ -7,11 +7,7 @@ import asyncio
|
||||
from aioesphomeapi import EntityState, TextSensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
assert_device_name,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, assert_device_name, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -43,7 +39,7 @@ async def test_strftime_to(
|
||||
entities, "time_error_format", description="Time Error Format sensor"
|
||||
)
|
||||
|
||||
# Set up state tracking
|
||||
# Set up state tracking with InitialStateHelper
|
||||
loop = asyncio.get_running_loop()
|
||||
states: dict[int, TextSensorState] = {}
|
||||
all_received = loop.create_future()
|
||||
@@ -53,6 +49,7 @@ async def test_strftime_to(
|
||||
string_format.key,
|
||||
error_format.key,
|
||||
}
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, TextSensorState) and not state.missing_state:
|
||||
@@ -60,8 +57,11 @@ async def test_strftime_to(
|
||||
if expected_keys <= states.keys() and not all_received.done():
|
||||
all_received.set_result(True)
|
||||
|
||||
# Subscribe with the wrapper that filters initial states
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for initial states to be broadcast
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Wait for all expected states
|
||||
await wait_or_fail(
|
||||
|
||||
@@ -6,7 +6,7 @@ import aioesphomeapi
|
||||
from aioesphomeapi.model import APIIntEnum
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_only_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_only_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -66,11 +66,20 @@ async def test_template_alarm_control_panel_many_sensors(
|
||||
f"Got: {actual_sensor_names}"
|
||||
)
|
||||
|
||||
# Use InitialStateHelper to wait for all initial states
|
||||
state_helper = InitialStateHelper(entities)
|
||||
|
||||
def on_state(state: aioesphomeapi.EntityState) -> None:
|
||||
# We'll receive subsequent states here after initial states
|
||||
pass
|
||||
|
||||
client.subscribe_states(state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for all initial states
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
await state_helper.wait_for_initial_states(timeout=5.0)
|
||||
|
||||
# Verify the alarm state is disarmed initially
|
||||
alarm_state = sub.initial_states.get(alarm_info.key)
|
||||
alarm_state = state_helper.initial_states.get(alarm_info.key)
|
||||
assert alarm_state is not None, "Alarm control panel initial state not received"
|
||||
assert isinstance(alarm_state, aioesphomeapi.AlarmControlPanelEntityState)
|
||||
assert alarm_state.state == aioesphomeapi.AlarmControlPanelState.DISARMED, (
|
||||
@@ -79,7 +88,7 @@ async def test_template_alarm_control_panel_many_sensors(
|
||||
|
||||
# Verify all 10 binary sensors have initial states
|
||||
binary_sensor_states = [
|
||||
sub.initial_states.get(sensor.key) for sensor in binary_sensors
|
||||
state_helper.initial_states.get(sensor.key) for sensor in binary_sensors
|
||||
]
|
||||
assert all(state is not None for state in binary_sensor_states), (
|
||||
"Not all binary sensors have initial states"
|
||||
|
||||
@@ -27,9 +27,9 @@ import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -65,6 +65,7 @@ async def test_template_climate_basic(
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
# Advertised capabilities come straight from the supported_*/custom_* config lists.
|
||||
@@ -84,9 +85,12 @@ async def test_template_climate_basic(
|
||||
|
||||
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
initial = sub.initial_states.get(test_climate.key)
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
assert initial.current_temperature == pytest.approx(22.5, abs=0.1)
|
||||
|
||||
@@ -13,9 +13,9 @@ import pytest
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -47,6 +47,7 @@ async def test_template_climate_custom_modes(
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
assert set(test_climate.supported_custom_fan_modes) == {
|
||||
@@ -62,9 +63,12 @@ async def test_template_climate_custom_modes(
|
||||
|
||||
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
initial = sub.initial_states.get(test_climate.key)
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
assert initial.custom_fan_mode == ""
|
||||
|
||||
@@ -23,9 +23,9 @@ import pytest
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -55,15 +55,17 @@ async def test_template_climate_nonoptimistic(
|
||||
state_updates.append(state)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
confirm_button = require_entity(
|
||||
entities, "simulate_device_confirmation", ButtonInfo
|
||||
)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
initial = sub.initial_states.get(test_climate.key)
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
assert initial.mode == ClimateMode.OFF
|
||||
|
||||
@@ -13,11 +13,7 @@ import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, require_only_entity, wait_for_state
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "tmpl-clim-oc-order"
|
||||
@@ -47,9 +43,13 @@ async def test_template_climate_on_control_ordering(
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
await subscribe_after_initial_states(client, entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
client.climate_command(test_climate.key, mode=ClimateMode.HEAT)
|
||||
state = await wait_for_climate_state()
|
||||
|
||||
@@ -26,9 +26,9 @@ import pytest
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -58,11 +58,13 @@ async def test_template_climate_publish_all_fields(
|
||||
state_updates.append(state)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
require_only_entity(entities, ClimateInfo)
|
||||
|
||||
publish_button = require_entity(entities, "publish_all", ButtonInfo)
|
||||
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
client.button_command(publish_button.key)
|
||||
state = await wait_for_state(
|
||||
|
||||
@@ -16,9 +16,9 @@ import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -47,6 +47,7 @@ async def test_template_climate_sensor_push(
|
||||
state_updates.append(state)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
publish_temp = require_entity(entities, "publish_temperature", ButtonInfo)
|
||||
@@ -55,9 +56,10 @@ async def test_template_climate_sensor_push(
|
||||
)
|
||||
publish_humidity = require_entity(entities, "publish_humidity", ButtonInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
initial = sub.initial_states.get(test_climate.key)
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
# Neither backing sensor has published anything yet.
|
||||
|
||||
@@ -20,9 +20,9 @@ import pytest
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -49,6 +49,7 @@ async def test_template_climate_set_actions(
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
|
||||
report_button = require_entity(entities, "report_device_state", ButtonInfo)
|
||||
@@ -56,7 +57,10 @@ async def test_template_climate_set_actions(
|
||||
entities, "report_unsupported_mode", ButtonInfo
|
||||
)
|
||||
|
||||
await subscribe_after_initial_states(client, entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Both traits are derived from the low/high and humidity set actions, not declared.
|
||||
assert test_climate.supports_two_point_target_temperature
|
||||
|
||||
@@ -15,9 +15,9 @@ import pytest
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
require_entity,
|
||||
require_only_entity,
|
||||
subscribe_after_initial_states,
|
||||
wait_for_state,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -49,6 +49,7 @@ async def test_template_climate_two_point_temperature(
|
||||
)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_climate = require_only_entity(entities, ClimateInfo)
|
||||
assert test_climate.name == "Test Two-Point Heatpump"
|
||||
assert test_climate.supports_two_point_target_temperature
|
||||
@@ -56,9 +57,13 @@ async def test_template_climate_two_point_temperature(
|
||||
|
||||
report_button = require_entity(entities, "simulate_device_report", ButtonInfo)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda state: None)
|
||||
)
|
||||
|
||||
initial = sub.initial_states.get(test_climate.key)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
initial = initial_state_helper.initial_states.get(test_climate.key)
|
||||
assert initial is not None, "No initial climate state received"
|
||||
assert isinstance(initial, aioesphomeapi.ClimateState)
|
||||
# Nothing has been published yet: settable fields have no sensor to seed them from, so
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import TextInfo, TextState
|
||||
import pytest
|
||||
@@ -17,11 +18,7 @@ import pytest
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
assert_device_name,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, assert_device_name, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-template-text-save-test"
|
||||
@@ -63,17 +60,33 @@ async def test_template_text_save(
|
||||
entities, "test_text_restore", TextInfo, "Test Text Restore"
|
||||
)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
# Set up state tracking
|
||||
loop = asyncio.get_running_loop()
|
||||
state_futures: dict[int, asyncio.Future[Any]] = {}
|
||||
|
||||
def on_state(state: Any) -> None:
|
||||
if state.key in state_futures and not state_futures[state.key].done():
|
||||
state_futures[state.key].set_result(state)
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Verify initial value from config
|
||||
initial = sub.initial_states[text_entity.key]
|
||||
initial = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(initial, TextState)
|
||||
assert initial.state == "hello"
|
||||
|
||||
async def wait_for_state(key: int, timeout: float = 2.0) -> Any:
|
||||
state_futures[key] = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(state_futures[key], timeout)
|
||||
finally:
|
||||
state_futures.pop(key, None)
|
||||
|
||||
# Set a new value that fits within max_length
|
||||
next_text = sub.waiter.next_state(text_entity.key, TextState, timeout=2.0)
|
||||
client.text_command(key=text_entity.key, state="world")
|
||||
state = await next_text
|
||||
state = await wait_for_state(text_entity.key)
|
||||
assert state.state == "world"
|
||||
|
||||
# Set the same value again - should NOT produce "too long" warning
|
||||
@@ -99,10 +112,12 @@ async def test_template_text_save(
|
||||
entities, "test_text_restore", TextInfo, "Test Text Restore"
|
||||
)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# The value should be "world" - restored from preferences
|
||||
restored = sub.initial_states[text_entity.key]
|
||||
restored = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(restored, TextState)
|
||||
assert restored.state == "world", (
|
||||
f"Expected restored value 'world', got '{restored.state}'"
|
||||
|
||||
@@ -7,7 +7,7 @@ import asyncio
|
||||
from aioesphomeapi import EntityState, TextSensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import build_key_to_entity_mapping, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -39,7 +39,9 @@ async def test_template_time(
|
||||
if name is not None and state.state == EXPECTED_STATES[name]:
|
||||
events[name].set()
|
||||
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
for name, event in events.items():
|
||||
await wait_or_fail(
|
||||
|
||||
@@ -7,14 +7,13 @@ transmitted via the API.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import TextInfo, TextState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
assert_device_name,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, assert_device_name, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -25,6 +24,7 @@ async def test_text_command(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test text command with various string values including edge cases."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
# Verify we can get device info
|
||||
await assert_device_name(client, "host-text-command-test")
|
||||
@@ -41,57 +41,81 @@ async def test_text_command(
|
||||
entities, "test_text_long", TextInfo, "Test Text Long entity"
|
||||
)
|
||||
|
||||
# Track state changes
|
||||
states: dict[int, Any] = {}
|
||||
state_futures: dict[int, asyncio.Future[Any]] = {}
|
||||
|
||||
def on_state(state: Any) -> None:
|
||||
states[state.key] = state
|
||||
if state.key in state_futures and not state_futures[state.key].done():
|
||||
state_futures[state.key].set_result(state)
|
||||
|
||||
# Set up InitialStateHelper to swallow initial state broadcasts
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for all initial states to be received
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Verify initial states were received
|
||||
assert test_text.key in sub.initial_states
|
||||
initial_text_state = sub.initial_states[test_text.key]
|
||||
assert test_text.key in initial_state_helper.initial_states
|
||||
initial_text_state = initial_state_helper.initial_states[test_text.key]
|
||||
assert isinstance(initial_text_state, TextState)
|
||||
assert initial_text_state.state == "initial"
|
||||
|
||||
async def send_and_wait(key: int, value: str) -> TextState:
|
||||
"""Send a text command and return the entity's next state."""
|
||||
next_text = sub.waiter.next_state(key, TextState, timeout=2.0)
|
||||
client.text_command(key=key, state=value)
|
||||
return await next_text
|
||||
async def wait_for_state_change(key: int, timeout: float = 2.0) -> Any:
|
||||
"""Wait for a state change for the given entity key."""
|
||||
state_futures[key] = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(state_futures[key], timeout)
|
||||
finally:
|
||||
state_futures.pop(key, None)
|
||||
|
||||
# Test 1: Simple text value
|
||||
state = await send_and_wait(test_text.key, "hello world")
|
||||
client.text_command(key=test_text.key, state="hello world")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == "hello world"
|
||||
|
||||
# Test 2: Empty string (edge case for zero-copy)
|
||||
state = await send_and_wait(test_text.key, "")
|
||||
client.text_command(key=test_text.key, state="")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == ""
|
||||
|
||||
# Test 3: Single character
|
||||
state = await send_and_wait(test_text.key, "x")
|
||||
client.text_command(key=test_text.key, state="x")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == "x"
|
||||
|
||||
# Test 4: String with special characters
|
||||
state = await send_and_wait(test_text.key, "hello\tworld\n!")
|
||||
client.text_command(key=test_text.key, state="hello\tworld\n!")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == "hello\tworld\n!"
|
||||
|
||||
# Test 5: Unicode characters
|
||||
state = await send_and_wait(test_text.key, "hello 世界 🌍")
|
||||
client.text_command(key=test_text.key, state="hello 世界 🌍")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == "hello 世界 🌍"
|
||||
|
||||
# Test 6: Long string (tests buffer handling)
|
||||
long_text = "a" * 200
|
||||
state = await send_and_wait(test_text_long.key, long_text)
|
||||
client.text_command(key=test_text_long.key, state=long_text)
|
||||
state = await wait_for_state_change(test_text_long.key)
|
||||
assert state.state == long_text
|
||||
assert len(state.state) == 200
|
||||
|
||||
# Test 7: Password field (same mechanism, different mode)
|
||||
state = await send_and_wait(test_password.key, "newpassword123")
|
||||
client.text_command(key=test_password.key, state="newpassword123")
|
||||
state = await wait_for_state_change(test_password.key)
|
||||
assert state.state == "newpassword123"
|
||||
|
||||
# Test 8: String with null bytes embedded (edge case)
|
||||
# Note: protobuf strings should handle this but it's good to verify
|
||||
state = await send_and_wait(test_text.key, "before\x00after")
|
||||
client.text_command(key=test_text.key, state="before\x00after")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == "before\x00after"
|
||||
|
||||
# Test 9: Rapid successive commands (tests buffer reuse)
|
||||
for i in range(5):
|
||||
state = await send_and_wait(test_text.key, f"rapid_{i}")
|
||||
client.text_command(key=test_text.key, state=f"rapid_{i}")
|
||||
state = await wait_for_state_change(test_text.key)
|
||||
assert state.state == f"rapid_{i}"
|
||||
|
||||
@@ -22,11 +22,7 @@ from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -74,10 +70,14 @@ async def test_uart_mock_ld2410(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
# Set up initial state helper
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
@@ -188,10 +188,13 @@ async def test_uart_mock_ld2410_engineering(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -21,11 +21,7 @@ from aioesphomeapi import (
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
build_key_to_entity_mapping,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -117,7 +113,10 @@ async def test_uart_mock_ld2410_query_ack(
|
||||
f"Sensor entities missing, found: {sorted(sensor_key_to_name.values())}"
|
||||
)
|
||||
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
|
||||
@@ -41,11 +41,7 @@ import re
|
||||
from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState
|
||||
import pytest
|
||||
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -119,7 +115,9 @@ async def _start_scenario(client: APIClient, collector: SensorStateCollector) ->
|
||||
"""Subscribe the collector past the initial states, then start the mock scenario."""
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
await subscribe_after_initial_states(client, entities, on_state=collector.on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(collector.on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -196,10 +194,12 @@ async def test_uart_mock_ld2410s(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
max_distance_key = require_entity(entities, "max_distance", NumberInfo).key
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
# The values read from the module: gate 16 and 10 s
|
||||
no_delay_key = require_entity(entities, "no_delay", NumberInfo).key
|
||||
initial = sub.initial_states
|
||||
initial = initial_state_helper.initial_states
|
||||
assert initial[max_distance_key].state == pytest.approx(11.2)
|
||||
assert initial[no_delay_key].state == pytest.approx(10)
|
||||
|
||||
|
||||
@@ -32,15 +32,22 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState, NumberInfo, NumberState
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
SensorStateCollector,
|
||||
StateWaiter,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
@@ -75,6 +82,18 @@ def _threshold_numbers(entities: list[EntityInfo]) -> dict[str, NumberInfo]:
|
||||
}
|
||||
|
||||
|
||||
async def _wait_initial(
|
||||
client: APIClient,
|
||||
entities: list[EntityInfo],
|
||||
on_state: Callable[[EntityState], None] = lambda s: None,
|
||||
) -> InitialStateHelper:
|
||||
"""Subscribe to states and wait until every entity has sent its initial state."""
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
return initial_state_helper
|
||||
|
||||
|
||||
def _number_state_of(key: int) -> Callable[[EntityState], bool]:
|
||||
"""Return a predicate that matches a number state with a value for ``key``."""
|
||||
return lambda s: isinstance(s, NumberState) and not s.missing_state and s.key == key
|
||||
@@ -156,9 +175,7 @@ async def test_uart_mock_ld2412(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
)
|
||||
await _wait_initial(client, entities, collector.on_state)
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
@@ -258,9 +275,7 @@ async def test_uart_mock_ld2412_engineering(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
)
|
||||
await _wait_initial(client, entities, collector.on_state)
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
@@ -347,9 +362,7 @@ async def test_uart_mock_ld2412_engineering_truncated(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
)
|
||||
await _wait_initial(client, entities, collector.on_state)
|
||||
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
@@ -410,7 +423,7 @@ async def test_uart_mock_ld2412_footer_in_payload(
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = await _wait_initial(client, entities)
|
||||
numbers = _threshold_numbers(entities)
|
||||
|
||||
# Gate 5 move is the first footer byte inside the motion answer
|
||||
@@ -420,7 +433,7 @@ async def test_uart_mock_ld2412_footer_in_payload(
|
||||
"gate_0_still_threshold": 0.0,
|
||||
"gate_5_still_threshold": 25.0,
|
||||
}.items():
|
||||
state = sub.initial_states[numbers[name].key]
|
||||
state = initial_state_helper.initial_states[numbers[name].key]
|
||||
assert state.state == pytest.approx(value), name
|
||||
|
||||
motion_written = events.next_line_with(expected_motion)
|
||||
@@ -510,10 +523,10 @@ async def test_uart_mock_ld2412_truncated_answer(
|
||||
states.setdefault(state.key, []).append(state.state)
|
||||
state_waiter.on_state(state)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=record)
|
||||
initial_state_helper = await _wait_initial(client, entities, record)
|
||||
|
||||
# The late answer may already have been parsed when the client connected
|
||||
for state in sub.initial_states.values():
|
||||
for state in initial_state_helper.initial_states.values():
|
||||
record(state)
|
||||
|
||||
try:
|
||||
@@ -560,16 +573,17 @@ async def test_uart_mock_ld2412_requery_after_giveup(
|
||||
gate_5_key = numbers["gate_5_move_threshold"].key
|
||||
|
||||
gate_5_values: list[float] = []
|
||||
waiter = StateWaiter()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if _number_state_of(gate_5_key)(state):
|
||||
gate_5_values.append(state.state)
|
||||
waiter.on_state(state)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
waiter = sub.waiter
|
||||
initial_state_helper = await _wait_initial(client, entities, on_state)
|
||||
|
||||
for key in (gate_0_key, gate_5_key):
|
||||
assert sub.initial_states[key].missing_state
|
||||
assert initial_state_helper.initial_states[key].missing_state
|
||||
|
||||
held_back = events.next_line_with(MOTION_HELD_BACK)
|
||||
config_mode_left = events.next_line_with(CONFIG_MODE_LEFT)
|
||||
|
||||
@@ -23,11 +23,7 @@ from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -58,10 +54,14 @@ async def test_uart_mock_ld2420(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
# Set up initial state helper
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
@@ -151,10 +151,13 @@ async def test_uart_mock_ld2420_simple(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -8,11 +8,7 @@ from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -51,9 +47,11 @@ async def test_uart_mock_ld2420_config_exit(
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -21,11 +21,7 @@ from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -87,10 +83,14 @@ async def test_uart_mock_ld2450(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
# Set up initial state helper
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -12,7 +12,7 @@ import asyncio
|
||||
from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# The fixture sends one frame with a target, then 2 s of frames without one
|
||||
@@ -41,7 +41,9 @@ async def test_uart_mock_ld2450_has_target_only(
|
||||
if state.state and not turned_on.done():
|
||||
turned_on.set_result(None)
|
||||
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(helper.on_state_wrapper(on_state))
|
||||
await helper.wait_for_initial_states()
|
||||
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -7,6 +7,7 @@ Tests:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
|
||||
@@ -16,7 +17,7 @@ import pytest
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "uart-mock-ld2450-zone-restore"
|
||||
@@ -52,8 +53,10 @@ async def test_uart_mock_ld2450_polygon_zone_restore(
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
polygon = require_entity(entities, "restore_zone_polygon", TextInfo)
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
state = sub.initial_states[polygon.key]
|
||||
helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(helper.on_state_wrapper(lambda state: None))
|
||||
await helper.wait_for_initial_states()
|
||||
state = helper.initial_states[polygon.key]
|
||||
assert isinstance(state, TextState)
|
||||
return state.state
|
||||
|
||||
@@ -64,18 +67,25 @@ async def test_uart_mock_ld2450_polygon_zone_restore(
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
polygon = require_entity(entities, "restore_zone_polygon", TextInfo)
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
assert sub.initial_states[polygon.key].state == ""
|
||||
loop = asyncio.get_running_loop()
|
||||
published = loop.create_future()
|
||||
|
||||
def on_state(state: object) -> None:
|
||||
if (
|
||||
isinstance(state, TextState)
|
||||
and state.key == polygon.key
|
||||
and state.state == ZONE_POLYGON
|
||||
and not published.done()
|
||||
):
|
||||
published.set_result(None)
|
||||
|
||||
helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(helper.on_state_wrapper(on_state))
|
||||
await helper.wait_for_initial_states()
|
||||
assert helper.initial_states[polygon.key].state == ""
|
||||
|
||||
published = sub.waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, TextState)
|
||||
and s.key == polygon.key
|
||||
and s.state == ZONE_POLYGON
|
||||
)
|
||||
)
|
||||
client.text_command(polygon.key, ZONE_POLYGON)
|
||||
await published
|
||||
await asyncio.wait_for(published, timeout=5.0)
|
||||
|
||||
# Second run: the polygon is restored
|
||||
assert await initial_polygon() == ZONE_POLYGON
|
||||
|
||||
@@ -22,7 +22,7 @@ from aioesphomeapi import (
|
||||
import pytest
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "uart-mock-ld2450-zone-timeout"
|
||||
@@ -76,7 +76,9 @@ async def test_uart_mock_ld2450_polygon_zone_timeout(
|
||||
):
|
||||
timeout_set.set_result(None)
|
||||
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
client.text_command(polygon.key, ZONE_POLYGON)
|
||||
client.number_command(timeout.key, PRESENCE_TIMEOUT_S)
|
||||
|
||||
@@ -28,7 +28,7 @@ import pytest
|
||||
from esphome.components.ld2450.text import POLYGON_MAX_LENGTH, POLYGON_PATTERN
|
||||
|
||||
from .host_prefs import clear_host_prefs
|
||||
from .state_utils import require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
DEVICE_NAME = "uart-mock-ld2450-zones"
|
||||
@@ -64,22 +64,28 @@ async def test_uart_mock_ld2450_polygon_zones(
|
||||
presence_history: dict[str, list[bool | None]] = {
|
||||
name: [] for name in presences
|
||||
}
|
||||
waiter = StateWaiter()
|
||||
expect = waiter.expect
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, BinarySensorState) and state.key in presence_names:
|
||||
presence_history[presence_names[state.key]].append(
|
||||
None if state.missing_state else state.state
|
||||
)
|
||||
waiter.on_state(state)
|
||||
|
||||
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
expect = sub.waiter.expect
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
for name, polygon in polygons.items():
|
||||
# Home Assistant validates input and the zone card finds zones with these
|
||||
assert polygon.pattern == POLYGON_PATTERN
|
||||
assert polygon.max_length == POLYGON_MAX_LENGTH
|
||||
assert sub.initial_states[polygon.key].state == ""
|
||||
assert sub.initial_states[presences[name].key].missing_state
|
||||
assert initial_state_helper.initial_states[polygon.key].state == ""
|
||||
assert initial_state_helper.initial_states[
|
||||
presences[name].key
|
||||
].missing_state
|
||||
|
||||
async def set_polygon(name: str, value: str, expected: str) -> None:
|
||||
key = polygons[name].key
|
||||
|
||||
@@ -18,11 +18,7 @@ from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -76,9 +72,11 @@ async def test_uart_mock_ld2460(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -17,11 +17,7 @@ from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
@@ -92,9 +88,11 @@ async def test_uart_mock_pylontech(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
await subscribe_after_initial_states(
|
||||
client, entities, on_state=collector.on_state
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(collector.on_state)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
|
||||
client.button_command(start_btn.key)
|
||||
|
||||
@@ -6,12 +6,12 @@ field combinations and the lambda path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import ValveInfo, ValveOperation, ValveState
|
||||
from aioesphomeapi import ButtonInfo, EntityState, ValveInfo, ValveOperation, ValveState
|
||||
import pytest
|
||||
|
||||
from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -22,29 +22,51 @@ async def test_valve_control_action(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test valve ControlAction with constants and a lambda."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
valve_state_future: asyncio.Future[ValveState] | None = None
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, ValveState)
|
||||
and valve_state_future is not None
|
||||
and not valve_state_future.done()
|
||||
):
|
||||
valve_state_future.set_result(state)
|
||||
|
||||
async def wait_for_valve_state(timeout: float = 5.0) -> ValveState:
|
||||
nonlocal valve_state_future
|
||||
valve_state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(valve_state_future, timeout)
|
||||
finally:
|
||||
valve_state_future = None
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
require_entity(entities, "test_valve", ValveInfo)
|
||||
|
||||
press = partial(
|
||||
press_and_wait, client, entities, sub.waiter, state_type=ValveState
|
||||
)
|
||||
async def press_and_wait(name: str) -> ValveState:
|
||||
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
|
||||
client.button_command(btn.key)
|
||||
return await wait_for_valve_state()
|
||||
|
||||
# valve.control: position only
|
||||
state = await press("set_position")
|
||||
state = await press_and_wait("Set Position")
|
||||
assert state.position == pytest.approx(0.5, abs=0.01)
|
||||
|
||||
# valve.control: state alias for position 1.0
|
||||
state = await press("open_state")
|
||||
state = await press_and_wait("Open State")
|
||||
assert state.position == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
# valve.control: lambda position (test_position global = 0.42)
|
||||
state = await press("lambda_position")
|
||||
state = await press_and_wait("Lambda Position")
|
||||
assert state.position == pytest.approx(0.42, abs=0.01)
|
||||
|
||||
# valve.control: stop only — template valve's stop_action publishes
|
||||
# current_operation: IDLE.
|
||||
state = await press("stop_valve")
|
||||
state = await press_and_wait("Stop Valve")
|
||||
assert state.current_operation == ValveOperation.IDLE
|
||||
|
||||
@@ -14,7 +14,7 @@ from aioesphomeapi import (
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_only_entity, subscribe_after_initial_states
|
||||
from .state_utils import InitialStateHelper, require_only_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@@ -25,9 +25,32 @@ async def test_water_heater_template(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test template water heater basic state and mode changes."""
|
||||
loop = asyncio.get_running_loop()
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
states: dict[int, aioesphomeapi.EntityState] = {}
|
||||
state_future: asyncio.Future[WaterHeaterState] | None = None
|
||||
|
||||
def on_state(state: aioesphomeapi.EntityState) -> None:
|
||||
states[state.key] = state
|
||||
if (
|
||||
isinstance(state, WaterHeaterState)
|
||||
and state_future is not None
|
||||
and not state_future.done()
|
||||
):
|
||||
state_future.set_result(state)
|
||||
|
||||
async def wait_for_state(timeout: float = 5.0) -> WaterHeaterState:
|
||||
"""Wait for next water heater state change."""
|
||||
nonlocal state_future
|
||||
state_future = loop.create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(state_future, timeout)
|
||||
finally:
|
||||
state_future = None
|
||||
|
||||
# Get entities and set up state synchronization
|
||||
entities, services = await client.list_entities_services()
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
test_water_heater = require_only_entity(entities, WaterHeaterInfo)
|
||||
|
||||
# Verify water heater entity info
|
||||
@@ -49,11 +72,14 @@ async def test_water_heater_template(
|
||||
f"Expected 4 supported modes, got {len(supported_modes)}: {supported_modes}"
|
||||
)
|
||||
|
||||
# Subscribe with the wrapper that filters initial states
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
# Wait for all initial states to be broadcast
|
||||
sub = await subscribe_after_initial_states(client, entities)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Get initial state and verify
|
||||
initial_state = sub.initial_states.get(test_water_heater.key)
|
||||
initial_state = initial_state_helper.initial_states.get(test_water_heater.key)
|
||||
assert initial_state is not None, "Water heater initial state not found"
|
||||
assert isinstance(initial_state, WaterHeaterState)
|
||||
# Initial mode is OFF (default) since we don't have a mode lambda
|
||||
@@ -89,43 +115,37 @@ async def test_water_heater_template(
|
||||
)
|
||||
|
||||
# Test turning on away mode
|
||||
next_state = sub.waiter.next_state(test_water_heater.key, WaterHeaterState)
|
||||
client.water_heater_command(test_water_heater.key, away=True)
|
||||
away_on_state = await next_state
|
||||
away_on_state = await wait_for_state()
|
||||
assert (away_on_state.state & WaterHeaterStateFlag.AWAY) != 0
|
||||
# ON flag should still be set (is_on lambda returns true)
|
||||
assert (away_on_state.state & WaterHeaterStateFlag.ON) != 0
|
||||
|
||||
# Test turning off away mode
|
||||
next_state = sub.waiter.next_state(test_water_heater.key, WaterHeaterState)
|
||||
client.water_heater_command(test_water_heater.key, away=False)
|
||||
away_off_state = await next_state
|
||||
away_off_state = await wait_for_state()
|
||||
assert (away_off_state.state & WaterHeaterStateFlag.AWAY) == 0
|
||||
assert (away_off_state.state & WaterHeaterStateFlag.ON) != 0
|
||||
|
||||
# Test turning off (on=False)
|
||||
next_state = sub.waiter.next_state(test_water_heater.key, WaterHeaterState)
|
||||
client.water_heater_command(test_water_heater.key, on=False)
|
||||
off_state = await next_state
|
||||
off_state = await wait_for_state()
|
||||
assert (off_state.state & WaterHeaterStateFlag.ON) == 0
|
||||
assert (off_state.state & WaterHeaterStateFlag.AWAY) == 0
|
||||
|
||||
# Test turning back on (on=True)
|
||||
next_state = sub.waiter.next_state(test_water_heater.key, WaterHeaterState)
|
||||
client.water_heater_command(test_water_heater.key, on=True)
|
||||
on_state = await next_state
|
||||
on_state = await wait_for_state()
|
||||
assert (on_state.state & WaterHeaterStateFlag.ON) != 0
|
||||
|
||||
# Test changing to GAS mode
|
||||
next_state = sub.waiter.next_state(test_water_heater.key, WaterHeaterState)
|
||||
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.GAS)
|
||||
gas_state = await next_state
|
||||
gas_state = await wait_for_state()
|
||||
assert gas_state.mode == WaterHeaterMode.GAS
|
||||
|
||||
# Test changing to ECO mode (from GAS)
|
||||
next_state = sub.waiter.next_state(test_water_heater.key, WaterHeaterState)
|
||||
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO)
|
||||
eco_state = await next_state
|
||||
eco_state = await wait_for_state()
|
||||
assert eco_state.mode == WaterHeaterMode.ECO
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user