Compare commits

..
Author SHA1 Message Date
J. Nick Koston fef73dbd15 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 17:24:30 -10:00
J. Nick Koston e426e33427 [core] Assert a frame came back before each resync() in the FrameReader tests 2026-10-22 17:24:30 -10:00
J. Nick Koston 0628e4e492 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 16:44:21 -10:00
J. Nick Koston d5c6b01e19 [core] Cap FrameReader headers at 8 bytes to bound the replay depth 2026-10-22 16:44:21 -10:00
J. Nick Koston abbf3a6960 [sm300d2] Wait for the rest of a frame cut off at the end of the buffer 2026-10-22 16:44:12 -10:00
J. Nick Koston a97d4241bd [ds1603l] [ufm01] Read with read_available like the other FrameReader users 2026-10-22 16:33:33 -10:00
J. Nick Koston f1031bc64d [dfrobot_sen0465_uart] Read the answer with one read_available call 2026-10-22 16:31:31 -10:00
J. Nick Koston 57e14c1b54 [core] Wrap the AGENTS.md bus helpers lines at 120 characters 2026-10-22 16:13:15 -10:00
J. Nick Koston 71064efc25 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 16:13:15 -10:00
J. Nick Koston f5ea2d9f01 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:58:37 -10:00
J. Nick Koston 11a228569b [core] State when FrameReader::resync() may be called and list FrameReader in AGENTS.md 2026-10-22 15:58:36 -10:00
J. Nick Koston 5d69784adc [sm300d2] Read the frame with read_available and inline the checksum helper 2026-10-22 15:51:02 -10:00
J. Nick Koston 3b4deefd4c [core] Drop FrameReader's single byte header special case and deduce N in tests 2026-10-22 15:47:52 -10:00
J. Nick Koston 22a0440348 Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:47:52 -10:00
J. Nick Koston 46379d8cbc [ufm01] Brace the single log call body 2026-10-22 15:45:41 -10:00
J. Nick Koston f00328f45d Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:39:40 -10:00
J. Nick Koston ddfcc0bc8b [core] Name FrameReader's static header lookup without a trailing underscore 2026-10-22 15:39:36 -10:00
J. Nick Koston 61bd5e2657 [ufm01] Drop the start byte checks FrameReader already made 2026-10-22 15:22:36 -10:00
J. Nick Koston 7a1dc1279d [uart] Read bytes one at a time in the FrameReader users to keep flash down 2026-10-22 15:14:41 -10:00
J. Nick Koston 04165e289f Merge branch 'uart-frame-reader' into uart-frame-reader-migrate 2026-10-22 15:09:52 -10:00
J. Nick Koston 965b26dab6 [core] Realign a broken FrameReader header by replaying its bytes 2026-10-22 15:09:51 -10:00
J. Nick Koston a7e5a2a621 [uart] Collect fixed length frames with FrameReader in five components 2026-10-22 15:08:57 -10:00
J. Nick Koston 8824610f82 [core] Add FrameReader to collect fixed length binary frames 2026-10-22 15:04:08 -10:00
35 changed files with 418 additions and 1509 deletions

No files matched your search

+4 -3
View File
@@ -216,9 +216,10 @@ file does, and it is the authority when they disagree. The most useful starting
the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar<LAYOUT>`
and `encode_bcd_calendar<LAYOUT>` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`.
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
`LineReader<N>`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id`
and `read_bytes_16_le`. `if (!this->status_comm_result(ok)) return;` sets or clears the warning.
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
`LineReader<N>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; I2C has `update_register`,
`write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`.
`if (!this->status_comm_result(ok)) return;` sets or clears the warning. A child sensor that skips repeated
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
@@ -1,10 +1,7 @@
#include "dfrobot_sen0465_uart.h"
#include <algorithm>
namespace esphome::dfrobot_sen0465_uart {
using dfrobot_sen0465_base::FRAME_HEAD;
using dfrobot_sen0465_base::FRAME_SIZE;
using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID;
@@ -18,30 +15,21 @@ void DFRobotSEN0465UARTComponent::setup() {
}
void DFRobotSEN0465UARTComponent::loop() {
// The tail of a reading the probe sent on its own may precede the answer; start at the next frame head.
uint8_t byte;
while (this->rx_count_ == 0 && this->peek_byte(&byte) && byte != FRAME_HEAD)
this->read_byte(&byte);
const size_t available = this->available();
if (available == 0)
return;
const size_t len = std::min<size_t>(available, FRAME_SIZE - this->rx_count_);
if (!this->read_array(this->rx_buffer_ + this->rx_count_, len))
return;
this->rx_count_ += len;
if (this->rx_count_ < FRAME_SIZE)
// The tail of a reading the probe sent on its own may precede the answer; the reader skips to the frame head.
const uint8_t *frame = nullptr;
this->read_available<FRAME_SIZE>([&](uint8_t c) { return (frame = this->reader_.feed(c)) == nullptr; });
if (frame == nullptr)
return;
this->rx_count_ = 0;
this->cancel_timeout(RESPONSE_TIMEOUT_ID);
this->disable_loop();
this->handle_response_(this->rx_buffer_);
this->handle_response_(frame);
}
bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) {
// Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set
this->discard_available();
this->rx_count_ = 0;
this->reader_.reset();
this->write_array(frame, FRAME_SIZE);
return true;
}
@@ -50,7 +38,7 @@ void DFRobotSEN0465UARTComponent::read_response() {
this->enable_loop();
this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_TIMEOUT_MS, [this]() {
this->disable_loop();
this->rx_count_ = 0;
this->reader_.reset();
this->handle_no_response_();
});
}
@@ -2,6 +2,7 @@
#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/frame_reader.h"
namespace esphome::dfrobot_sen0465_uart {
@@ -17,8 +18,7 @@ class DFRobotSEN0465UARTComponent final : public dfrobot_sen0465_base::DFRobotSE
bool write_frame(const uint8_t *frame) override;
void read_response() override;
uint8_t rx_buffer_[dfrobot_sen0465_base::FRAME_SIZE];
uint8_t rx_count_{0};
FrameReader<dfrobot_sen0465_base::FRAME_SIZE, dfrobot_sen0465_base::FRAME_HEAD> reader_;
};
} // namespace esphome::dfrobot_sen0465_uart
+15 -36
View File
@@ -1,7 +1,5 @@
#include "ds1603l.h"
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -10,35 +8,28 @@ namespace esphome::ds1603l {
ESPHOME_LOG_TAG(TAG, "ds1603l.sensor");
void DS1603L::loop() {
// Assemble frames one byte at a time so a stream that starts mid-frame can realign
uint8_t byte;
while (this->available() > 0 && this->read_byte(&byte)) {
if (this->rx_count_ == 0 && byte != HEADER_BYTE) {
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
continue;
this->read_available([this](uint8_t byte) {
const uint8_t *frame = this->reader_.feed(byte);
if (frame == nullptr) {
if (this->reader_.length() == 0) {
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
}
return;
}
this->rx_buffer_[this->rx_count_++] = byte;
if (this->rx_count_ < FRAME_SIZE) {
continue;
}
if (this->parse_data_()) {
this->rx_count_ = 0;
} else {
if (!this->parse_data_(frame)) {
// The header byte was part of the payload of a misaligned frame, so realign instead of dropping everything
this->resync_();
this->reader_.resync();
}
}
});
}
void DS1603L::dump_config() { LOG_SENSOR("", "DS1603L", this); }
bool DS1603L::parse_data_() {
uint8_t header = this->rx_buffer_[0];
uint8_t data_h = this->rx_buffer_[1];
uint8_t data_l = this->rx_buffer_[2];
uint8_t checksum = this->rx_buffer_[3];
bool DS1603L::parse_data_(const uint8_t *frame) {
uint8_t header = frame[0];
uint8_t data_h = frame[1];
uint8_t data_l = frame[2];
uint8_t checksum = frame[3];
uint8_t computed_checksum = (header + data_h + data_l) & 0xFF;
@@ -53,16 +44,4 @@ bool DS1603L::parse_data_() {
return true;
}
void DS1603L::resync_() {
// Drop the byte that was treated as the header, then look for the next candidate header in what is left
size_t start = 1;
while (start < this->rx_count_ && this->rx_buffer_[start] != HEADER_BYTE) {
start++;
}
this->rx_count_ -= start;
if (this->rx_count_ > 0) {
memmove(this->rx_buffer_, this->rx_buffer_ + start, this->rx_count_);
}
}
} // namespace esphome::ds1603l
+4 -6
View File
@@ -6,6 +6,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/frame_reader.h"
namespace esphome::ds1603l {
@@ -18,13 +19,10 @@ class DS1603L final : public sensor::Sensor, public Component, public uart::UART
static constexpr uint8_t HEADER_BYTE = 0xFF;
static constexpr size_t FRAME_SIZE = 4;
// Validates the checksum of the frame in rx_buffer_ and publishes it. Returns false if the frame is invalid.
bool parse_data_();
// Drops the first buffered byte and realigns the buffer on the next possible header byte.
void resync_();
// Validates the checksum of the frame and publishes it. Returns false if the frame is invalid.
bool parse_data_(const uint8_t *frame);
uint8_t rx_buffer_[FRAME_SIZE]; // Buffer for the frame being assembled
size_t rx_count_{0}; // Number of bytes currently in rx_buffer_
FrameReader<FRAME_SIZE, HEADER_BYTE> reader_;
};
} // namespace esphome::ds1603l
@@ -1,6 +1,5 @@
#include "download_buffer.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
namespace esphome::online_image {
@@ -35,46 +34,6 @@ 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,6 +1,5 @@
#pragma once
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
@@ -36,8 +35,6 @@ 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_;
@@ -46,35 +43,4 @@ 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
-12
View File
@@ -22,8 +22,6 @@ 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")
@@ -45,9 +43,7 @@ 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({}),
}
)
@@ -100,17 +96,9 @@ 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"),
)
+10 -144
View File
@@ -4,7 +4,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
ESPHOME_LOG_TAG(TAG, "online_image");
@@ -16,11 +15,6 @@ 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)
@@ -146,12 +140,8 @@ void OnlineImage::update() {
return;
}
// 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() &&
// JPEG requires the complete image in the download buffer before decoding
if (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_();
@@ -162,8 +152,6 @@ 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();
}
@@ -181,11 +169,6 @@ 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 &&
@@ -196,22 +179,15 @@ void OnlineImage::loop() {
}
// Download and decode more data
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);
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 (received > 0) {
this->last_data_time_ = now;
this->report_progress_(false);
if (len > 0) {
this->last_data_time_ = App.get_loop_component_start_time();
this->download_buffer_.write(len);
// Feed data to decoder
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
@@ -279,114 +255,6 @@ 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();
@@ -420,8 +288,6 @@ void OnlineImage::end_connection_() {
this->downloader_ = nullptr;
}
this->download_buffer_.reset();
this->streaming_ = false;
this->stream_started_ = false;
this->disable_loop();
}
@@ -16,10 +16,6 @@ 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
@@ -74,17 +70,9 @@ 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));
}
@@ -93,20 +81,12 @@ 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_
@@ -129,9 +109,6 @@ 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,6 +2,7 @@
#include "runtime_image.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::runtime_image {
@@ -10,8 +11,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->draw_width_ = width;
this->draw_height_ = height;
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
return success;
}
@@ -19,24 +20,10 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
if (!this->size_valid_) {
return;
}
// 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++) {
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++) {
this->image_->draw_pixel(i, j, color);
}
}
@@ -6,15 +6,6 @@
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,
@@ -92,8 +83,8 @@ class ImageDecoder {
this->expected_size_ = 0;
this->decoded_bytes_ = 0;
this->size_valid_ = true;
this->draw_width_ = 0;
this->draw_height_ = 0;
this->x_scale_ = 1.0;
this->y_scale_ = 1.0;
}
/**
@@ -193,9 +184,8 @@ class ImageDecoder {
uint8_t *image_buffer_() const;
bool image_is_big_endian_() const;
// Size of the image as the decoder draws it, which draw() spreads over the buffer
int draw_width_ = 0;
int draw_height_ = 0;
double x_scale_ = 1.0;
double y_scale_ = 1.0;
RuntimeImage *image_;
size_t expected_size_ = 0; // Expected data size (0 if unknown)
size_t decoded_bytes_ = 0; // Bytes processed so far
@@ -28,14 +28,16 @@ bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
this->source_height_ = height;
this->x_boundaries_.clear();
this->y_boundaries_.clear();
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)));
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));
}
for (int y = 0; y <= height; y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(y, height, target_height)));
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));
}
return true;
}
@@ -136,7 +138,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, this->scale_option_()))
if (!this->jpeg_.decode(0, 0, 0))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
@@ -163,28 +165,10 @@ int JpegDecoder::start_() {
} else {
this->jpeg_.setPixelType(RGB8888);
}
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
if (!this->set_size(width, height)) {
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
// 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)) {
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
return 0;
@@ -268,7 +252,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
if (!this->jpeg_.decodeBegin(0, 0, 0))
return this->fail_();
return 0;
}
@@ -31,16 +31,12 @@ 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_();
@@ -53,7 +49,6 @@ 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,9 +116,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
}
case image::IMAGE_TYPE_GRAYSCALE: {
uint32_t pos = this->get_position_(x, y);
// 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);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
+10 -18
View File
@@ -1,33 +1,27 @@
#include "sm300d2.h"
#include "esphome/core/frame_reader.h"
#include "esphome/core/log.h"
namespace esphome::sm300d2 {
ESPHOME_LOG_TAG(TAG, "sm300d2");
static const uint8_t SM300D2_RESPONSE_LENGTH = 17;
static constexpr uint8_t SM300D2_RESPONSE_LENGTH = 17;
void SM300D2Sensor::update() {
uint8_t response[SM300D2_RESPONSE_LENGTH];
uint8_t peeked;
// Takes the first frame already received; only one cut off at the end of the buffer is waited for
FrameReader<SM300D2_RESPONSE_LENGTH, 0x3C, 0x02> reader;
const uint8_t *response = nullptr;
this->read_available<1>([&](uint8_t c) { return (response = reader.feed(c)) == nullptr; });
for (uint8_t c; response == nullptr && reader.length() > 0 && this->read_byte(&c);)
response = reader.feed(c);
while (this->available() > 0 && this->peek_byte(&peeked) && peeked != 0x3C)
this->read();
bool read_success = read_array(response, SM300D2_RESPONSE_LENGTH);
if (!read_success) {
if (response == nullptr) {
ESP_LOGW(TAG, "Reading data from SM300D2 failed!");
status_set_warning();
return;
}
if (response[0] != 0x3C || response[1] != 0x02) {
ESP_LOGW(TAG, "Invalid preamble for SM300D2 response!");
this->status_set_warning();
return;
}
uint16_t calculated_checksum = this->sm300d2_checksum_(response);
uint16_t calculated_checksum = sum_bytes(response, SM300D2_RESPONSE_LENGTH - 1);
// Occasionally the checksum has a +/- 0x80 offset. Negative temperatures are
// responsible for some of these. The rest are unknown/undocumented.
if ((calculated_checksum != response[SM300D2_RESPONSE_LENGTH - 1]) &&
@@ -83,8 +77,6 @@ void SM300D2Sensor::update() {
this->humidity_sensor_->publish_state(humidity);
}
uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); }
void SM300D2Sensor::dump_config() {
ESP_LOGCONFIG(TAG, "SM300D2:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
-2
View File
@@ -20,8 +20,6 @@ class SM300D2Sensor final : public PollingComponent, public uart::UARTDevice {
void dump_config() override;
protected:
uint16_t sm300d2_checksum_(uint8_t *ptr);
sensor::Sensor *co2_sensor_{nullptr};
sensor::Sensor *formaldehyde_sensor_{nullptr};
sensor::Sensor *tvoc_sensor_{nullptr};
+22 -39
View File
@@ -44,8 +44,6 @@ static constexpr std::array<uint8_t, 7> GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11
static constexpr size_t FRAME_DEVICE_ID_INDEX = 2;
static constexpr size_t FRAME_CHECKSUM_INDEX = 30;
static constexpr size_t FRAME_STOP_INDEX = 31;
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64;
static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96;
static constexpr uint8_t FRAME_STOP_BYTE = 0x16;
@@ -120,9 +118,8 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp
static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) {
const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX);
return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") &&
check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") &&
check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") &&
// FrameReader already matched the start bytes
return check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") &&
check_byte(data, FRAME_INDEX_RESERVED_SECTION, FRAME_FLAG_RESERVED_SECTION, "reserved section flag") &&
check_byte(data, FRAME_INDEX_TEMP_FLAG, FRAME_FLAG_TEMP, "temperature flag") &&
check_byte(data, FRAME_CHECKSUM_INDEX, sum, "checksum") &&
@@ -215,7 +212,7 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) {
void UFM01Component::flush_rx_() {
this->discard_available();
this->read_index_ = 0;
this->active_reader_.reset();
}
void UFM01Component::send_command_no_wait_(const std::array<uint8_t, 7> &command) {
@@ -423,7 +420,7 @@ bool UFM01Component::service_software_version_(uint32_t now) {
}
#endif // USE_UFM01_SOFTWARE_VERSION
void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) {
void UFM01Component::on_active_frame_(const uint8_t data[FRAME_SIZE], uint32_t now) {
#ifdef USE_UFM01_METER_ID
this->publish_meter_id_from_frame_(data);
#endif
@@ -461,40 +458,26 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) {
bool UFM01Component::process_active_stream_(uint32_t now) {
bool got_valid_frame = false;
while (this->available()) {
if (!this->read_byte(&this->data_[this->read_index_])) {
ESP_LOGW(TAG, "unable to read byte");
this->read_index_ = 0;
continue;
}
if ((this->read_index_ == 0 && this->data_[0] != FRAME_START_BYTE_1) ||
(this->read_index_ == 1 && this->data_[1] != FRAME_START_BYTE_2)) {
ESP_LOGD(TAG, "not start of data at %" PRId32 " (is 0x%02X)", this->read_index_, this->data_[this->read_index_]);
this->read_index_ = 0;
continue;
}
if (++this->read_index_ < static_cast<int32_t>(FRAME_SIZE))
continue;
if (validate_active_frame(this->data_)) {
this->on_active_frame_(this->data_, now);
this->read_index_ = 0;
got_valid_frame = true;
continue;
}
log_hex(this->data_, sizeof(this->data_));
ESP_LOGW(TAG, "unable to read data");
for (int32_t i = 2;
i < static_cast<int32_t>(FRAME_STOP_INDEX) && this->read_index_ == static_cast<int32_t>(FRAME_SIZE); ++i) {
if ((this->data_[i] == FRAME_START_BYTE_1) && (this->data_[i + 1] == FRAME_START_BYTE_2)) {
for (int32_t j = i; j < static_cast<int32_t>(FRAME_SIZE); ++j)
this->data_[j - i] = this->data_[j];
this->read_index_ = static_cast<int32_t>(FRAME_SIZE) - i;
const bool read_ok = this->read_available([this, now, &got_valid_frame](uint8_t byte) {
const uint8_t *frame = this->active_reader_.feed(byte);
if (frame == nullptr) {
if (this->active_reader_.length() == 0) {
ESP_LOGD(TAG, "not start of data (is 0x%02X)", byte);
}
return;
}
if (this->read_index_ == static_cast<int32_t>(FRAME_SIZE))
this->read_index_ = 0;
if (validate_active_frame(frame)) {
this->on_active_frame_(frame, now);
got_valid_frame = true;
return;
}
log_hex(frame, FRAME_SIZE);
ESP_LOGW(TAG, "unable to read data");
this->active_reader_.resync();
});
if (!read_ok) {
ESP_LOGW(TAG, "unable to read byte");
this->active_reader_.reset();
}
return got_valid_frame;
+5 -3
View File
@@ -2,6 +2,7 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/frame_reader.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
@@ -25,6 +26,8 @@ class TestableUFM01;
} // namespace testing
static constexpr size_t FRAME_SIZE = 32;
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
static constexpr size_t PASSIVE_FRAME_SIZE = 23;
static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39;
#ifdef USE_UFM01_METER_ID
@@ -108,7 +111,7 @@ class UFM01Component : public uart::UARTDevice, public Component {
bool consume_ack_();
void flush_rx_();
bool process_active_stream_(uint32_t now);
void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now);
void on_active_frame_(const uint8_t data[FRAME_SIZE], uint32_t now);
#ifdef USE_UFM01_METER_ID
void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]);
#endif
@@ -145,7 +148,6 @@ class UFM01Component : public uart::UARTDevice, public Component {
#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION
uint32_t pending_clear_start_ms_{0};
#endif
int32_t read_index_{0};
OperatingMode operating_mode_{OperatingMode::STARTUP};
StartupPhase startup_phase_{StartupPhase::WAIT};
@@ -170,7 +172,7 @@ class UFM01Component : public uart::UARTDevice, public Component {
#endif
uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE];
uint8_t data_[FRAME_SIZE];
FrameReader<FRAME_SIZE, FRAME_START_BYTE_1, FRAME_START_BYTE_2> active_reader_;
friend class testing::TestableUFM01;
};
+10 -42
View File
@@ -4,70 +4,38 @@
namespace esphome::wts01 {
constexpr uint8_t HEADER_1 = 0x55;
constexpr uint8_t HEADER_2 = 0x01;
constexpr uint8_t HEADER_3 = 0x01;
constexpr uint8_t HEADER_4 = 0x04;
ESPHOME_LOG_TAG(TAG, "wts01");
void WTS01Sensor::loop() {
this->read_available([this](uint8_t c) { this->handle_char_(c); });
this->read_available([this](uint8_t c) {
if (const uint8_t *packet = this->reader_.feed(c)) {
this->process_packet_(packet);
}
});
}
void WTS01Sensor::dump_config() { LOG_SENSOR("", "WTS01 Sensor", this); }
void WTS01Sensor::handle_char_(uint8_t c) {
// State machine for processing the header. Reset if something doesn't match.
if (this->buffer_pos_ == 0 && c != HEADER_1) {
return;
}
if (this->buffer_pos_ == 1 && c != HEADER_2) {
this->buffer_pos_ = 0;
return;
}
if (this->buffer_pos_ == 2 && c != HEADER_3) {
this->buffer_pos_ = 0;
return;
}
if (this->buffer_pos_ == 3 && c != HEADER_4) {
this->buffer_pos_ = 0;
return;
}
// Add byte to buffer
this->buffer_[this->buffer_pos_++] = c;
// Process complete packet
if (this->buffer_pos_ >= PACKET_SIZE) {
this->process_packet_();
this->buffer_pos_ = 0;
}
}
void WTS01Sensor::process_packet_() {
void WTS01Sensor::process_packet_(const uint8_t *packet) {
// Based on Tasmota implementation
// Format: 55 01 01 04 01 11 16 12 95
// header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum
const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1);
const uint8_t calculated_checksum = sum_bytes(packet, PACKET_SIZE - 1);
uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1];
uint8_t received_checksum = packet[PACKET_SIZE - 1];
if (calculated_checksum != received_checksum) {
ESP_LOGW(TAG, "WTS01 Checksum doesn't match: 0x%02X != 0x%02X", received_checksum, calculated_checksum);
return;
}
// Extract temperature value
const uint8_t raw = this->buffer_[6];
const uint8_t raw = packet[6];
// WTS01 encodes sign in bit 7, magnitude in bits 0-6
const bool negative = (raw & 0x80) != 0;
const uint8_t magnitude = raw & 0x7F;
const float decimal = static_cast<float>(this->buffer_[7]) / 100.0f;
const float decimal = static_cast<float>(packet[7]) / 100.0f;
float temperature = static_cast<float>(magnitude) + decimal;
+3 -4
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/frame_reader.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
@@ -14,11 +15,9 @@ class WTS01Sensor final : public sensor::Sensor, public uart::UARTDevice, public
void dump_config() override;
protected:
uint8_t buffer_[PACKET_SIZE];
uint8_t buffer_pos_{0};
void process_packet_(const uint8_t *packet);
void handle_char_(uint8_t c);
void process_packet_();
FrameReader<PACKET_SIZE, 0x55, 0x01, 0x01, 0x04> reader_;
};
} // namespace esphome::wts01
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <type_traits>
namespace esphome {
/// Collects fixed length binary frames of N bytes that start with the header bytes HEADER...; the binary twin of
/// LineReader. Bytes before a header are skipped; checksums are left to the caller.
template<size_t N, uint8_t... HEADER> class FrameReader {
using pos_t = std::conditional_t<(N <= 0xFF), uint8_t, uint16_t>;
static constexpr size_t HEADER_LEN = sizeof...(HEADER);
static_assert(HEADER_LEN >= 1 && HEADER_LEN <= 8, "FrameReader takes 1 to 8 header bytes"); // bounds replay_() depth
static_assert(HEADER_LEN < N && N <= 0xFFFF, "FrameReader size out of range");
public:
/// Feed one byte; returns the completed frame, header included, valid until the next call, or nullptr.
const uint8_t *feed(uint8_t c) {
pos_t pos = this->pos_;
if (pos < HEADER_LEN && c != header_byte(pos)) {
// A later byte of the broken header, or c, may start the real one
this->buf_[pos] = c;
this->replay_(pos);
return nullptr;
}
this->buf_[pos++] = c;
if (pos < N) {
this->pos_ = pos;
return nullptr;
}
this->pos_ = 0;
return this->buf_;
}
/// Number of bytes of the partial frame; 0 after a frame is returned and while looking for a header.
size_t length() const { return this->pos_; }
/// Forget the partial frame and look for a header from the next byte.
void reset() { this->pos_ = 0; }
/// For a frame the caller rejected (bad checksum): drop its first byte and realign on a header in the rest.
/// Call only right after feed() returned that frame; use reset() otherwise.
void resync() { this->replay_(N - 1); }
protected:
// A fold, not an array, so no header table lands in RAM on ESP8266
static constexpr uint8_t header_byte(size_t i) {
size_t j = 0;
uint8_t r = 0;
((r = (j++ == i) ? HEADER : r), ...);
return r;
}
// Start over and feed buf_[1..last] again; never completes a frame as last < N
void replay_(size_t last) {
this->pos_ = 0;
for (size_t i = 1; i <= last; i++) {
this->feed(this->buf_[i]);
}
}
uint8_t buf_[N];
pos_t pos_{0};
};
} // namespace esphome
+143
View File
@@ -0,0 +1,143 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/core/frame_reader.h"
namespace esphome::core::testing {
using Bytes = std::vector<uint8_t>;
using Frames = std::vector<Bytes>;
// Feeds bytes and collects every completed frame.
template<size_t N, uint8_t... H> static Frames feed_all(FrameReader<N, H...> &reader, const Bytes &input) {
Frames frames;
for (uint8_t c : input) {
const uint8_t *frame = reader.feed(c);
if (frame != nullptr) {
EXPECT_EQ(reader.length(), 0u);
frames.emplace_back(frame, frame + N);
}
}
return frames;
}
static_assert(sizeof(FrameReader<4, 0xFF>) == 5);
static_assert(sizeof(FrameReader<9, 0x55, 0x01, 0x01, 0x04>) == 10);
static_assert(sizeof(FrameReader<0xFF, 0xAA>) == 0x100);
static_assert(sizeof(FrameReader<0x100, 0xAA>) == 0x102);
TEST(FrameReaderTest, SingleByteHeader) {
FrameReader<4, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0xFF, 0x01, 0x02, 0x02, 0xFF, 0x03, 0x04, 0x06}),
(Frames{{0xFF, 0x01, 0x02, 0x02}, {0xFF, 0x03, 0x04, 0x06}}));
}
TEST(FrameReaderTest, SkipsBytesBeforeHeader) {
FrameReader<3, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0x01, 0x02, 0xFF, 0x10, 0x20}), (Frames{{0xFF, 0x10, 0x20}}));
}
TEST(FrameReaderTest, HeaderByteInsidePayloadIsData) {
FrameReader<4, 0xFF> reader;
EXPECT_EQ(feed_all(reader, {0xFF, 0xFF, 0xFF, 0x00}), (Frames{{0xFF, 0xFF, 0xFF, 0x00}}));
}
TEST(FrameReaderTest, LengthTracksPartialFrame) {
FrameReader<4, 0xAA, 0xBB> reader;
EXPECT_EQ(reader.length(), 0u);
reader.feed(0x00);
EXPECT_EQ(reader.length(), 0u);
reader.feed(0xAA);
EXPECT_EQ(reader.length(), 1u);
reader.feed(0xBB);
reader.feed(0x01);
EXPECT_EQ(reader.length(), 3u);
reader.reset();
EXPECT_EQ(reader.length(), 0u);
EXPECT_EQ(feed_all(reader, {0x02, 0xAA, 0xBB, 0x03, 0x04}), (Frames{{0xAA, 0xBB, 0x03, 0x04}}));
}
TEST(FrameReaderTest, MultiByteHeaderMismatchRestarts) {
FrameReader<6, 0x55, 0x01, 0x01, 0x04> reader;
EXPECT_EQ(feed_all(reader, {0x55, 0x01, 0x02, 0x55, 0x01, 0x01, 0x04, 0x11, 0x22}),
(Frames{{0x55, 0x01, 0x01, 0x04, 0x11, 0x22}}));
}
TEST(FrameReaderTest, MismatchedByteCanStartHeader) {
FrameReader<6, 0x55, 0x01, 0x01, 0x04> reader;
// The second 0x55 breaks the first header and starts the real one
EXPECT_EQ(feed_all(reader, {0x55, 0x01, 0x55, 0x01, 0x01, 0x04, 0x11, 0x22}),
(Frames{{0x55, 0x01, 0x01, 0x04, 0x11, 0x22}}));
}
TEST(FrameReaderTest, RepeatedHeaderPrefixIsKept) {
// AA AA AA BB: the mismatch at the third byte still leaves AA AA matched
FrameReader<4, 0xAA, 0xAA, 0xBB> reader;
EXPECT_EQ(feed_all(reader, {0xAA, 0xAA, 0xAA, 0xBB, 0x01}), (Frames{{0xAA, 0xAA, 0xBB, 0x01}}));
}
TEST(FrameReaderTest, OverlappingHeaderPrefix) {
// A1 B2 A1 B2: the mismatch on the last B2 keeps the trailing A1 B2 as the start of the header
FrameReader<5, 0xA1, 0xB2, 0xA1, 0xC3> reader;
EXPECT_EQ(feed_all(reader, {0xA1, 0xB2, 0xA1, 0xB2, 0xA1, 0xC3, 0x07}), (Frames{{0xA1, 0xB2, 0xA1, 0xC3, 0x07}}));
}
TEST(FrameReaderTest, ResyncFindsHeaderInRejectedFrame) {
FrameReader<4, 0xFF> reader;
// Started mid-frame: the 0xFF taken as a header was payload; the real frame starts at the second 0xFF
const uint8_t *frame = nullptr;
for (uint8_t c : {0xFF, 0x12, 0xFF, 0x01}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 2u);
EXPECT_EQ(feed_all(reader, {0x02, 0x02}), (Frames{{0xFF, 0x01, 0x02, 0x02}}));
}
TEST(FrameReaderTest, ResyncWithoutHeaderStartsOver) {
FrameReader<4, 0xFF> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0xFF, 0x01, 0x02, 0x03}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 0u);
}
TEST(FrameReaderTest, ResyncKeepsPartialHeaderAtEnd) {
FrameReader<5, 0x3C, 0x32> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0x3C, 0x32, 0x00, 0x00, 0x3C}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 1u);
EXPECT_EQ(feed_all(reader, {0x32, 0x01, 0x02, 0x03}), (Frames{{0x3C, 0x32, 0x01, 0x02, 0x03}}));
}
TEST(FrameReaderTest, ResyncMultiByteHeader) {
FrameReader<6, 0x3C, 0x32> reader;
const uint8_t *frame = nullptr;
for (uint8_t c : {0x3C, 0x32, 0x3C, 0x3C, 0x32, 0x09}) {
frame = reader.feed(c);
}
ASSERT_NE(frame, nullptr);
reader.resync();
EXPECT_EQ(reader.length(), 3u);
EXPECT_EQ(feed_all(reader, {0x0A, 0x0B, 0x0C}), (Frames{{0x3C, 0x32, 0x09, 0x0A, 0x0B, 0x0C}}));
}
TEST(FrameReaderTest, SixteenBitIndex) {
FrameReader<300, 0xAA, 0x55> reader;
Bytes input{0x00, 0xAA, 0x55};
input.resize(input.size() + 298, 0x42);
Frames frames = feed_all(reader, input);
ASSERT_EQ(frames.size(), 1u);
EXPECT_EQ(frames[0][0], 0xAA);
EXPECT_EQ(frames[0][1], 0x55);
EXPECT_EQ(frames[0][299], 0x42);
}
} // namespace esphome::core::testing
@@ -19,11 +19,6 @@ 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,7 +2,6 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <memory>
@@ -28,9 +27,6 @@ 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;
@@ -46,21 +42,13 @@ 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 {
@@ -135,173 +123,13 @@ 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, uint32_t buffer_size = 256) {
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format) {
auto img = std::make_unique<OnlineImage>("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB,
image::TRANSPARENCY_OPAQUE, nullptr, buffer_size);
image::TRANSPARENCY_OPAQUE, nullptr, 256);
img->set_parent(&this->http_);
return img;
}
@@ -458,294 +286,4 @@ 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,13 +3,10 @@
#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 {
@@ -365,23 +362,6 @@ 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);
@@ -397,12 +377,6 @@ 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) {
@@ -412,12 +386,16 @@ static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *o
}
TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
// 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);
// 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,
};
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);
@@ -427,45 +405,9 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
const uint8_t *little = rgb565_little_endian.get_data_start();
const uint8_t *big = rgb565_big_endian.get_data_start();
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";
}
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);
}
}
@@ -473,43 +415,10 @@ TEST(RuntimeImageDecoder, JpegDecodesToScaledRgb) {
// 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; }
// 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;
}
// 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 scaled direct decode must equal the unscaled one sampled through the boundary table,
@@ -518,8 +427,7 @@ TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) {
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
// Sizes JPEGDEC cannot reach by scaling, so the full size decode is spread over the buffer
for (int size : {5, 6, 16}) {
for (int size : {4, 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);
@@ -1,35 +0,0 @@
#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,7 +5,6 @@
#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
@@ -179,7 +178,6 @@ 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
@@ -293,23 +291,6 @@ 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));
+3 -5
View File
@@ -20,8 +20,6 @@
namespace esphome::ufm01::testing {
static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C;
static constexpr uint8_t FRAME_START_BYTE_2 = 0x32;
static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64;
static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96;
static constexpr uint8_t FRAME_STOP_BYTE = 0x16;
@@ -131,7 +129,7 @@ class TestableUFM01 : public UFM01Component {
StartupPhase startup_phase() const { return this->startup_phase_; }
int32_t read_index() const { return this->read_index_; }
int32_t read_index() const { return this->active_reader_.length(); }
uint8_t passive_index() const { return this->passive_index_; }
@@ -165,7 +163,7 @@ class TestableUFM01 : public UFM01Component {
this->operating_mode_ = OperatingMode::ACTIVE_STREAM;
this->last_valid_frame_ms_ = now();
this->passive_read_pending_ = false;
this->read_index_ = 0;
this->active_reader_.reset();
}
void begin_active_wait_frame() {
@@ -182,7 +180,7 @@ class TestableUFM01 : public UFM01Component {
}
void reset_state() {
this->read_index_ = 0;
this->active_reader_.reset();
this->last_valid_frame_ms_ = 0;
this->passive_index_ = 0;
this->passive_read_pending_ = false;
@@ -30,6 +30,15 @@ TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) {
EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u);
}
TEST_F(UFM01Test, RejectedActiveFrameResyncsOnStartBytesInside) {
// A cut off frame start makes the first 32 bytes invalid; the real frame starts inside them
this->mock_uart_.enqueue({FRAME_START_BYTE_1, FRAME_START_BYTE_2, 0x00, 0x00});
this->mock_uart_.enqueue(make_active_frame());
EXPECT_TRUE(this->ufm01_.process_active_stream());
EXPECT_EQ(this->ufm01_.read_index(), 0);
}
TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) {
auto frame = make_passive_frame();
this->mock_uart_.enqueue(frame);
+59 -55
View File
@@ -58,30 +58,8 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
- Provides debug logging to track state reception (use `--log-cli-level=DEBUG`)
- Safe for concurrent use with multiple entity types
#### Subscribing after the initial states (`state_utils.py`)
`subscribe_after_initial_states()` does the `InitialStateHelper` setup in one call: it subscribes, waits
for every entity's connect time state, and fails the test with the entities still missing on timeout.
It returns a `StateSubscription` with `initial_states` (by key) and a `waiter`, a `StateWaiter` that sees
every later state. `waiter.next_state(key=..., state_type=...)` arms a wait for the next matching state;
call it before sending the command. `press_and_wait()` presses a button and returns the next state of a
type, from one entity when `key=` is given:
```python
from .state_utils import press_and_wait, subscribe_after_initial_states
entities, _ = await client.list_entities_services()
sub = await subscribe_after_initial_states(client, entities)
state = await press_and_wait(client, entities, sub.waiter, "set_position", CoverState)
assert state.position == pytest.approx(0.5, abs=0.01)
next_light = sub.waiter.next_state(key=light.key, state_type=LightState)
client.light_command(key=light.key, state=True)
assert (await next_light).state is True
```
Pass `on_state=` as well when the test also needs to collect every state.
**Future work:**
Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts.
#### Entity Lookup Helpers (`state_utils.py`)
@@ -225,24 +203,46 @@ async def test_my_sensor(
##### State Subscription Pattern
**Recommended: Using subscribe_after_initial_states**
**Recommended: Using InitialStateHelper**
When an API client connects, ESPHome automatically sends the current state of all entities.
`subscribe_after_initial_states()` (from `state_utils.py`) swallows these initial states and only forwards
subsequent state changes, to your callback and to the returned `waiter`:
When an API client connects, ESPHome automatically sends the current state of all entities. The `InitialStateHelper` (from `state_utils.py`) handles this by swallowing these initial states and only forwarding subsequent state changes to your test callback:
```python
from .state_utils import subscribe_after_initial_states
from .state_utils import InitialStateHelper
# Track state changes with futures
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
"""This callback only receives NEW state changes, not initial states."""
states[state.key] = state
# Check for specific condition using isinstance
if isinstance(state, SensorState) and state.state == expected_value:
if not state_future.done():
state_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
sub = await subscribe_after_initial_states(client, entities)
initial_state_helper = InitialStateHelper(entities)
# Arm the wait before the action that produces the state
expected = sub.waiter.expect(
lambda s: isinstance(s, SensorState) and s.state == expected_value
)
# 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; on timeout this fails the test
# and lists the entities that never sent one
await initial_state_helper.wait_for_initial_states()
# Now perform your test actions - on_state will only receive new changes
# ... trigger state changes ...
result = await expected
# Wait for expected state; on timeout the test fails and shows the states received
result = await wait_or_fail(
state_future, 5.0, "Expected state not received", states=states
)
```
**Legacy: Manual State Tracking**
@@ -386,33 +386,37 @@ async with run_compiled(yaml_config, line_callback=on_log_line):
# Test implementation
```
To wait for log lines, use `LineWaiter` (`log_utils.py`) instead of a hand written
callback with one future per pattern:
Example using futures for specific log patterns:
```python
import re
from .log_utils import LineWaiter
loop = asyncio.get_running_loop()
connected_future = loop.create_future()
service_future = loop.create_future()
log = LineWaiter()
# Arm expectations before the action that logs them; any number may be pending.
connected = log.expect(re.compile(r"Client .* connected from"))
value = log.expect(re.compile(r"value=(\d+)"))
# Patterns to match
connected_pattern = re.compile(r"Client .* connected from")
service_pattern = re.compile(r"Service called")
async with run_compiled(yaml_config, line_callback=log.callback):
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not connected_future.done() and connected_pattern.search(line):
connected_future.set_result(True)
elif not service_future.done() and service_pattern.search(line):
service_future.set_result(True)
async with run_compiled(yaml_config, line_callback=check_output):
async with api_client_connected() as client:
await log.wait(connected)
match = await log.wait(value, timeout=5.0, msg="no value logged")
assert match.group(1) == "42"
assert log.count("Service called") == 1
```
# Wait for specific log message
await asyncio.wait_for(connected_future, timeout=5.0)
- `expect(pattern)` only matches lines that arrive after the call. A `str` matches as a
substring and resolves to the line; a compiled regex is searched and resolves to its match.
Each expectation resolves on the next matching line, so two armed for the same text both
resolve on its first occurrence: arm the second after the first resolves, or use `count`.
- `wait(future)` fails the test on timeout with the pattern and the last 20 log lines.
- `wait_for(*needles)` also matches lines seen before the call, but only one may be pending.
- `count(pattern)` counts the lines seen so far.
# Do test actions...
# Wait for service log
await asyncio.wait_for(service_future, timeout=5.0)
```
**Note**: Tests that monitor log messages typically have fewer race conditions compared to state-based testing, making them more reliable. However, be aware that the host platform currently does not have a thread-safe logger, so logging from threads will not work correctly.
+3 -62
View File
@@ -3,47 +3,23 @@
from __future__ import annotations
import asyncio
import re
from typing import Any, overload
from .wait_utils import wait_or_fail
type LinePattern = str | re.Pattern[str]
type LineMatch = str | re.Match[str]
def _match(pattern: LinePattern, line: str) -> LineMatch | None:
"""Return ``line`` if a str pattern is in it, or the search match of a regex."""
if isinstance(pattern, str):
return line if pattern in line else None
return pattern.search(line)
class LineWaiter:
"""Collects log lines and lets a test await lines that match.
"""Collects log lines and lets a test await one containing all needles.
Pass ``callback`` as ``run_compiled``'s ``line_callback``; the callback runs
on the test's own event loop, so futures are resolved directly. ``wait_for``
also matches lines seen before the call, and only one may be outstanding at a
time. ``expect`` matches only later lines, and any number may be armed at once;
each resolves on the next matching line, so two armed for the same text both
resolve on its first occurrence. Arm the second after the first, or use ``count``.
on the test's own event loop, so futures are resolved directly. Only one
``wait_for`` may be outstanding at a time (tests await sequentially).
"""
def __init__(self) -> None:
self.lines: list[str] = []
self._needles: tuple[str, ...] = ()
self._future: asyncio.Future | None = None
self._expectations: dict[asyncio.Future[Any], LinePattern] = {}
def callback(self, line: str) -> None:
self.lines.append(line)
for future, pattern in list(self._expectations.items()):
if future.done():
del self._expectations[future]
elif (match := _match(pattern, line)) is not None:
future.set_result(match)
del self._expectations[future]
if (
self._future is not None
and not self._future.done()
@@ -70,38 +46,3 @@ class LineWaiter:
finally:
self._future = None
self._needles = ()
@overload
def expect(self, pattern: str) -> asyncio.Future[str]: ... # noqa: E704
@overload
def expect(self, pattern: re.Pattern[str]) -> asyncio.Future[re.Match[str]]: ... # noqa: E704
def expect(self, pattern: LinePattern) -> asyncio.Future[LineMatch]:
"""Arm a wait for the next line matching ``pattern``; earlier lines never match.
A str matches as a substring and resolves to the line; a compiled regex is
searched and resolves to its match. Arm it before the action that logs the line.
"""
future: asyncio.Future[LineMatch] = asyncio.get_running_loop().create_future()
self._expectations[future] = pattern
return future
async def wait[T: str | re.Match[str]](
self,
future: asyncio.Future[T],
timeout: float = 5.0,
msg: str = "log line not seen",
) -> T:
"""Await a future from ``expect``; on timeout fail with its pattern and the last lines."""
return await wait_or_fail(
future,
timeout,
msg,
pattern=self._expectations.get(future),
lines=self.lines,
)
def count(self, pattern: LinePattern) -> int:
"""Return how many lines seen so far match ``pattern``."""
return sum(_match(pattern, line) is not None for line in self.lines)
-69
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import TypeVar
@@ -101,22 +100,6 @@ class StateWaiter:
self._waiters.append(entry)
return self._wait(entry, timeout, label)
def next_state[T: EntityState](
self,
key: int | None = None,
state_type: type[T] = EntityState,
timeout: float = 5.0,
) -> Awaitable[T]:
"""Arm a wait for the next ``state_type`` state, for entity ``key`` if given.
Like :meth:`expect`, call it before sending the command and await the result afterwards.
"""
return self.expect(
lambda s: isinstance(s, state_type) and (key is None or s.key == key),
timeout,
state_type.__name__ if key is None else f"{state_type.__name__} key={key}",
)
async def _wait(
self,
entry: tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]],
@@ -419,58 +402,6 @@ class InitialStateHelper:
)
@dataclass(frozen=True, slots=True)
class StateSubscription:
"""A state subscription that is past the connect time states."""
initial_states: dict[int, EntityState]
waiter: StateWaiter
async def subscribe_after_initial_states(
client: APIClient,
entities: list[EntityInfo],
on_state: Callable[[EntityState], None] | None = None,
timeout: float = 5.0,
) -> StateSubscription:
"""Subscribe to states and wait until every entity has sent its connect time state.
Later states go to ``on_state`` (if given) and to the returned ``waiter``. The connect time
states are kept in ``initial_states`` by key. Fails the test listing the entities still missing.
"""
helper = InitialStateHelper(entities)
waiter = StateWaiter()
def forward(state: EntityState) -> None:
waiter.on_state(state)
if on_state is not None:
on_state(state)
client.subscribe_states(helper.on_state_wrapper(forward))
await helper.wait_for_initial_states(timeout)
return StateSubscription(helper.initial_states, waiter)
async def press_and_wait[T: EntityState](
client: APIClient,
entities: list[EntityInfo],
waiter: StateWaiter,
button: str,
state_type: type[T],
*,
key: int | None = None,
timeout: float = 5.0,
) -> T:
"""Press the button whose object_id contains ``button`` and return the next ``state_type`` state.
Pass the entity's ``key`` when the fixture has more than one entity of that type.
"""
btn = require_entity(entities, button, ButtonInfo)
next_state = waiter.next_state(key=key, state_type=state_type, timeout=timeout)
client.button_command(btn.key)
return await next_state
class SensorStateCollector:
"""Collects sensor, binary sensor, and text sensor state updates with wait helpers.
@@ -1,81 +0,0 @@
"""Tests for the integration test LineWaiter."""
from __future__ import annotations
import asyncio
import re
import pytest
from tests.integration.log_utils import LineWaiter
@pytest.mark.asyncio
async def test_expect_matches_only_later_lines() -> None:
waiter = LineWaiter()
waiter.callback("[I][app]: ready")
ready = waiter.expect("ready")
value = waiter.expect(re.compile(r"value=(\d+)"))
assert not ready.done()
waiter.callback("[D][x]: value=42")
waiter.callback("[I][app]: ready again")
assert await waiter.wait(ready) == "[I][app]: ready again"
match = await waiter.wait(value)
assert isinstance(match, re.Match)
assert match.group(1) == "42"
# wait_for still matches lines seen before the call.
assert await waiter.wait_for("app", "ready") == "[I][app]: ready"
@pytest.mark.asyncio
async def test_wait_timeout_reports_pattern_and_lines() -> None:
waiter = LineWaiter()
for i in range(30):
waiter.callback(f"line {i}")
future = waiter.expect("never")
with pytest.raises(pytest.fail.Exception) as err:
await waiter.wait(future, 0.01, "boot message missing")
message = str(err.value)
assert message.startswith("boot message missing (after 0.01s)")
assert "pattern: 'never'" in message
assert "lines (last 20 of 30):" in message
assert message.endswith("line 29")
assert future.cancelled()
# The cancelled expectation is dropped on the next line.
waiter.callback("never")
assert not waiter._expectations
def test_count() -> None:
waiter = LineWaiter()
for line in ("tick 1", "tock", "tick 2"):
waiter.callback(line)
assert waiter.count("tick") == 2
assert waiter.count(re.compile(r"t\w+k$")) == 1
assert waiter.count("missing") == 0
@pytest.mark.asyncio
async def test_expect_resolves_from_callback_while_waiting() -> None:
waiter = LineWaiter()
first = waiter.expect("first")
second = waiter.expect("second")
loop = asyncio.get_running_loop()
loop.call_soon(waiter.callback, "second")
loop.call_soon(waiter.callback, "first")
assert await waiter.wait(first) == "first"
assert await waiter.wait(second) == "second"
@pytest.mark.asyncio
async def test_line_waiter_expectations_share_a_matching_line() -> None:
waiter = LineWaiter()
first = waiter.expect("tick")
second = waiter.expect("tick")
waiter.callback("tick 1")
assert first.result() == second.result() == "tick 1"
third = waiter.expect("tick")
waiter.callback("tick 2")
assert third.result() == "tick 2"
assert waiter.count("tick") == 2
@@ -1,108 +0,0 @@
"""Tests for the state subscription helpers in tests/integration/state_utils.py."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from aioesphomeapi import ButtonInfo, EntityState, SensorInfo, SensorState, SwitchState
import pytest
from tests.integration.state_utils import (
StateSubscription,
StateWaiter,
press_and_wait,
subscribe_after_initial_states,
)
ENTITIES = [
SensorInfo(object_id="temp", key=1),
SensorInfo(object_id="humidity", key=2),
ButtonInfo(object_id="set_temp", key=3),
]
class FakeClient:
"""Records the state callback and button presses."""
def __init__(self) -> None:
self.callback: Callable[[EntityState], None] | None = None
self.pressed: list[int] = []
def subscribe_states(self, callback: Callable[[EntityState], None]) -> None:
self.callback = callback
def button_command(self, key: int) -> None:
self.pressed.append(key)
self.callback(SensorState(key=1, state=42.0))
async def _subscribe(client: FakeClient, **kwargs: object) -> StateSubscription:
task = asyncio.create_task(
subscribe_after_initial_states(client, ENTITIES, **kwargs)
)
await asyncio.sleep(0)
client.callback(SensorState(key=1, state=1.0))
client.callback(SensorState(key=2, state=2.0))
return await task
@pytest.mark.asyncio
async def test_subscribe_swallows_initial_states_and_forwards_later_ones() -> None:
client = FakeClient()
received: list[EntityState] = []
sub = await _subscribe(client, on_state=received.append)
assert sub.initial_states[1].state == 1.0
assert sub.initial_states[2].state == 2.0
assert received == []
next_state = sub.waiter.next_state(key=2, state_type=SensorState)
client.callback(SensorState(key=1, state=3.0))
client.callback(SensorState(key=2, state=4.0))
assert (await next_state).state == 4.0
assert [s.state for s in received] == [3.0, 4.0]
@pytest.mark.asyncio
async def test_subscribe_timeout_lists_missing_entities() -> None:
client = FakeClient()
task = asyncio.create_task(
subscribe_after_initial_states(client, ENTITIES, timeout=0.01)
)
await asyncio.sleep(0)
client.callback(SensorState(key=1, state=1.0))
with pytest.raises(pytest.fail.Exception, match=r"still_waiting[\s\S]*humidity"):
await task
@pytest.mark.asyncio
async def test_next_state_filters_by_type_and_times_out() -> None:
waiter = StateWaiter()
next_switch = waiter.next_state(state_type=SwitchState)
waiter.on_state(SensorState(key=1, state=1.0))
waiter.on_state(SwitchState(key=5, state=True))
assert (await next_switch).key == 5
with pytest.raises(TimeoutError, match="SwitchState key=9"):
await waiter.next_state(key=9, state_type=SwitchState, timeout=0.01)
@pytest.mark.asyncio
async def test_press_and_wait_presses_button_and_returns_state() -> None:
client = FakeClient()
sub = await _subscribe(client)
state = await press_and_wait(client, ENTITIES, sub.waiter, "set_temp", SensorState)
assert client.pressed == [3]
assert state.state == 42.0
@pytest.mark.asyncio
async def test_press_and_wait_waits_for_the_given_key() -> None:
client = FakeClient()
sub = await _subscribe(client)
pending = asyncio.ensure_future(
press_and_wait(client, ENTITIES, sub.waiter, "set_temp", SensorState, key=2)
)
await asyncio.sleep(0)
assert not pending.done() # the press published key 1
client.callback(SensorState(key=2, state=55.0))
assert (await pending).state == 55.0