Compare commits

..
Author SHA1 Message Date
clydeandClaude Opus 5.5 c43f039631 [runtime_image] Share one scale_boundary() between draw() and the JPEG RGB565 tables; test uneven scales
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:20:30 +11:00
clydeandClaude Opus 5.5 8d1476d585 [runtime_image] Use draw_width_ and draw_height_ in the JPEG decoder
The scale fields were removed when drawing moved to integer maths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 25b8c93ad6 [runtime_image] Check once per draw() call whether the image is scaled
An image drawn at its own size needs no span maths at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 5b1642b721 [runtime_image] Say the generic draw() path keeps the same pixels as the RGB565 path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 b62e12007f [runtime_image] Skip the span division for an image drawn at its own size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 68d24cb215 [runtime_image] Draw each target pixel once, with integer maths
ImageDecoder::draw() painted every source pixel into the buffer, so a
shrunk image wrote each target pixel many times, and worked out the
spans with double multiplications and ceil() for every pixel. The S3
has no double FPU, so drawing took about 6 us a pixel: 3.7 s of a 1000
x 617 PNG's 6 s. Give each source pixel the span from ceil(start *
scale) to ceil(end * scale), worked out with integers, so neighbouring
spans meet without overlapping. The PNG now loads in 3.2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
Clyde StubbsandClaude Opus 5.5 dcfad5c439 [runtime_image] Decode at the smallest possible JPEGDEC scale (#21386)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:57:05 +11:00
Clyde StubbsandClaude Opus 5.5 e08cbc5661 [online_image] Add on_download_started and on_download_progress (#21355)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:13 +11:00
Clyde StubbsandClaude Opus 5.5 2b183be544 [online_image] Decode a JPEG as it downloads (#21342)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:13 +11:00
21 changed files with 1010 additions and 597 deletions

No files matched your search

+64 -52
View File
@@ -186,19 +186,23 @@ static constexpr uint8_t CMD_DURATION_VALUE = 0x02;
// Bitmasks for target states
static constexpr uint8_t MOVE_BITMASK = 0x01;
static constexpr uint8_t STILL_BITMASK = 0x02;
using ld24xx::CMD_FRAME_FOOTER;
using ld24xx::CMD_FRAME_HEADER;
using ld24xx::HEADER_FOOTER_SIZE;
using ld24xx::validate_header_footer;
// Smallest frames whose values can all be read: the last value, data footer and check, then the frame footer
static constexpr uint8_t PERIODIC_FRAME_MIN = STILL_ENERGY + 3 + HEADER_FOOTER_SIZE;
static constexpr uint8_t ENGINEERING_FRAME_MIN = LIGHT_SENSOR + 3 + HEADER_FOOTER_SIZE;
// Header & Footer size
static constexpr uint8_t HEADER_FOOTER_SIZE = 4;
// Command Header & Footer
static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA};
static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01};
// Data Header & Footer
static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE;
// The length field counts the command word, the status word and the gates
static constexpr uint16_t GATE_SENS_ACK_LENGTH = GATE_SENS_ACK_SIZE - COMMAND - HEADER_FOOTER_SIZE;
static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) {
return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0;
}
void LD2412Component::dump_config() {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -367,9 +371,6 @@ void LD2412Component::handle_periodic_data_() {
0x02: Normal mode
*/
bool engineering_mode = this->buffer_data_[DATA_TYPES] == 0x01;
if (this->buffer_pos_ < PERIODIC_FRAME_MIN) {
return; // too short to hold the values
}
#ifdef USE_SWITCH
if (this->engineering_mode_switch_ != nullptr) {
this->engineering_mode_switch_->publish_state(engineering_mode);
@@ -420,7 +421,8 @@ void LD2412Component::handle_periodic_data_() {
this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance);
}
if (engineering_mode) {
if (this->buffer_pos_ < ENGINEERING_FRAME_MIN) {
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
if (this->buffer_pos_ < LIGHT_SENSOR + 1) {
ESP_LOGW(TAG, "Engineering mode packet too short: %u", this->buffer_pos_);
} else {
/*
@@ -475,15 +477,24 @@ static constexpr uint32_t GATE_QUERY_TIMEOUT_ID = 0;
static constexpr uint32_t GATE_QUERY_TIMEOUT_MS = 2000;
#endif
void LD2412Component::handle_ack_data_() {
bool LD2412Component::handle_ack_data_() {
ESP_LOGV(TAG, "Handling ACK DATA for COMMAND %02X", this->buffer_data_[COMMAND]);
if (this->buffer_pos_ < 10) {
ESP_LOGW(TAG, "Invalid length");
return true;
}
if (!ld2412::validate_header_footer(CMD_FRAME_HEADER, this->buffer_data_)) {
char hex_buf[format_hex_pretty_size(HEADER_FOOTER_SIZE)];
ESP_LOGW(TAG, "Invalid header: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, HEADER_FOOTER_SIZE));
return true;
}
if (this->buffer_data_[COMMAND_STATUS] != 0x01) {
ESP_LOGW(TAG, "Invalid status");
return;
return true;
}
if (this->buffer_data_[8] || this->buffer_data_[9]) {
ESP_LOGW(TAG, "Invalid command: %02X, %02X", this->buffer_data_[8], this->buffer_data_[9]);
return;
return true;
}
switch (this->buffer_data_[COMMAND]) {
@@ -506,8 +517,8 @@ void LD2412Component::handle_ack_data_() {
break;
case CMD_QUERY_VERSION: {
if (this->buffer_pos_ < 12 + sizeof(this->version_) + HEADER_FOOTER_SIZE) {
break; // too short to hold a version
if (this->buffer_pos_ < 12 + sizeof(this->version_)) {
return false;
}
std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_));
char version_s[20];
@@ -521,9 +532,6 @@ void LD2412Component::handle_ack_data_() {
break;
}
case CMD_QUERY_DISTANCE_RESOLUTION: {
if (this->buffer_pos_ < 11 + HEADER_FOOTER_SIZE) {
break; // too short to hold the resolution
}
const auto *distance_resolution = find_str(DISTANCE_RESOLUTIONS_BY_UINT, this->buffer_data_[10]);
ESP_LOGV(TAG, "Distance resolution: %s", distance_resolution);
#ifdef USE_SELECT
@@ -535,9 +543,6 @@ void LD2412Component::handle_ack_data_() {
}
case CMD_QUERY_LIGHT_CONTROL: {
if (this->buffer_pos_ < 12 + HEADER_FOOTER_SIZE) {
break; // too short to hold the light settings
}
this->light_function_ = this->buffer_data_[10];
this->light_threshold_ = this->buffer_data_[11];
const auto *light_function_str = find_str(LIGHT_FUNCTIONS_BY_UINT, this->light_function_);
@@ -557,7 +562,7 @@ void LD2412Component::handle_ack_data_() {
case CMD_QUERY_MAC_ADDRESS: {
if (this->buffer_pos_ < 20) {
break; // too short to hold a MAC address
return false;
}
this->bluetooth_on_ = std::memcmp(&this->buffer_data_[10], NO_MAC, sizeof(NO_MAC)) != 0;
@@ -586,9 +591,6 @@ void LD2412Component::handle_ack_data_() {
break;
case CMD_QUERY_DYNAMIC_BACKGROUND_CORRECTION: {
if (this->buffer_pos_ < 11 + HEADER_FOOTER_SIZE) {
break; // too short to hold the state
}
ESP_LOGV(TAG, "Handled query dynamic background correction");
bool dynamic_background_correction_active = (this->buffer_data_[10] != 0x00);
#ifdef USE_BINARY_SENSOR
@@ -611,10 +613,14 @@ void LD2412Component::handle_ack_data_() {
case CMD_QUERY_MOTION_GATE_SENS:
case CMD_QUERY_STATIC_GATE_SENS: {
#ifdef USE_NUMBER
if (this->buffer_pos_ != GATE_SENS_ACK_SIZE) {
// A length this driver does not know
const uint16_t declared = encode_uint16(this->buffer_data_[FRAME_LENGTH + 1], this->buffer_data_[FRAME_LENGTH]);
if (declared != GATE_SENS_ACK_LENGTH || this->buffer_pos_ > GATE_SENS_ACK_SIZE) {
// A truncated answer merged with the next frame, or a length this driver does not know
ESP_LOGW(TAG, "Dropping gate threshold answer of %u bytes", this->buffer_pos_);
return;
return true;
}
if (this->buffer_pos_ < GATE_SENS_ACK_SIZE) {
return false; // the footer pattern appeared inside the payload, keep reading
}
const bool motion = this->buffer_data_[COMMAND] == CMD_QUERY_MOTION_GATE_SENS;
GateThresholds &group = this->gate_thresholds_(motion);
@@ -640,8 +646,8 @@ void LD2412Component::handle_ack_data_() {
case CMD_QUERY_BASIC_CONF: // Query parameters response
{
#ifdef USE_NUMBER
if (this->buffer_pos_ < 15 + HEADER_FOOTER_SIZE) {
break; // too short to hold the configuration
if (this->buffer_pos_ < 15) {
return false;
}
/*
Moving distance range: 9th byte
@@ -671,36 +677,42 @@ void LD2412Component::handle_ack_data_() {
default:
break;
}
return true;
}
void LD2412Component::readline_(int readch) {
if (readch < 0) {
return; // No data available
}
// Periodic and command frames both carry their count after the header
ld24xx::feed_frame_byte(
this->buffer_data_, this->buffer_pos_, readch, DATA_FRAME_HEADER, ld24xx::cmd_frame_length,
[this](uint8_t frame_len) { return this->handle_frame_(frame_len); }, []() { ESP_LOGW(TAG, "Invalid length"); });
}
// Dispatches the frame of frame_len bytes at the start of the buffer when its footer is in place
bool LD2412Component::handle_frame_(uint8_t frame_len) {
this->buffer_pos_ = frame_len; // the handlers take the frame length from here
const uint8_t *tail = &this->buffer_data_[frame_len - HEADER_FOOTER_SIZE];
if (this->buffer_data_[0] == DATA_FRAME_HEADER[0]) {
if (!ld2412::validate_header_footer(DATA_FRAME_FOOTER, tail)) {
return false;
}
if (this->buffer_pos_ < HEADER_FOOTER_SIZE && readch != DATA_FRAME_HEADER[this->buffer_pos_] &&
readch != CMD_FRAME_HEADER[this->buffer_pos_]) {
this->buffer_pos_ = 0;
return;
}
if (this->buffer_pos_ < MAX_LINE_LENGTH - 1) {
this->buffer_data_[this->buffer_pos_++] = readch;
this->buffer_data_[this->buffer_pos_] = 0;
} else {
// We should never get here, but just in case...
ESP_LOGW(TAG, "Max command length exceeded; ignoring");
this->buffer_pos_ = 0;
}
if (this->buffer_pos_ < 4) {
return; // Not enough data to process yet
}
if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) {
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s");
this->handle_periodic_data_();
return true;
this->buffer_pos_ = 0; // Reset position index for next message
} else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) {
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s");
if (this->handle_ack_data_()) {
this->buffer_pos_ = 0; // Reset position index for next message
} else {
ESP_LOGV(TAG, "Ack Data incomplete");
}
}
if (!ld2412::validate_header_footer(CMD_FRAME_FOOTER, tail)) {
return false;
}
ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s");
this->handle_ack_data_();
return true;
}
void LD2412Component::set_config_mode_(bool enable) {
+1 -2
View File
@@ -111,8 +111,7 @@ class LD2412Component final : public Component, public uart::UARTDevice {
void send_command_(uint8_t command_str, const uint8_t *command_value, uint8_t command_value_len);
void set_config_mode_(bool enable);
void handle_periodic_data_();
void handle_ack_data_();
bool handle_frame_(uint8_t frame_len);
bool handle_ack_data_();
void readline_(int readch);
void query_parameters_();
void get_version_();
-70
View File
@@ -3,8 +3,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <cstring>
#include <memory>
#include <span>
@@ -55,74 +53,6 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
version[2]);
}
// Commands and their answers share this header, a little-endian byte count and this footer
static constexpr uint8_t HEADER_FOOTER_SIZE = 4;
static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xFD, 0xFC, 0xFB, 0xFA};
static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0x04, 0x03, 0x02, 0x01};
inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) {
return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0;
}
// Whole command frame length, or 0 until the count arrives; 32 bit so 0xFFFF cannot wrap to 0
inline uint32_t cmd_frame_length(const uint8_t *buffer, uint8_t count) {
if (count < HEADER_FOOTER_SIZE + 2) {
return 0;
}
return HEADER_FOOTER_SIZE + 2 + encode_value_le<uint16_t>(&buffer[HEADER_FOOTER_SIZE]) + HEADER_FOOTER_SIZE;
}
// Drops the first `from` bytes and anything after them that does not begin a header; returns the bytes kept
inline uint8_t drop_to_next_header(uint8_t *buffer, uint8_t count, uint8_t from, const uint8_t *data_header) {
for (; from < count; from++) {
const size_t n = std::min<size_t>(HEADER_FOOTER_SIZE, count - from);
if (std::memcmp(&buffer[from], CMD_FRAME_HEADER, n) == 0 || std::memcmp(&buffer[from], data_header, n) == 0) {
std::memmove(buffer, &buffer[from], count - from);
return count - from;
}
}
return 0;
}
// Feeds one byte into a frame buffer. A frame opens with the command header or `data_header` and ends at
// `frame_length(buffer, count)` bytes (0 while unknown); `handle_frame(len)` returns false for a footer out
// of place. A dropped or oversized frame calls `on_drop()`. The handler may overwrite `pos`; the splitter
// works from the count it saved.
template<size_t N, typename LengthFn, typename HandleFn, typename DropFn>
void feed_frame_byte(uint8_t (&buffer)[N], uint8_t &pos, uint8_t byte, const uint8_t *data_header,
LengthFn frame_length, HandleFn handle_frame, DropFn on_drop) {
// The first byte picks the header; bytes that do not continue it are dropped
if (pos > 0 && pos < HEADER_FOOTER_SIZE) {
const uint8_t *header = buffer[0] == data_header[0] ? data_header : CMD_FRAME_HEADER;
if (byte != header[pos]) {
pos = 0;
}
}
if (pos == 0 && byte != data_header[0] && byte != CMD_FRAME_HEADER[0]) {
return;
}
buffer[pos++] = byte;
// Drain every whole frame left in the buffer
while (true) {
const uint32_t frame_len = frame_length(buffer, pos);
if (frame_len == 0 || (frame_len <= N && pos < frame_len)) {
return;
}
const uint8_t total = pos;
// Keep only what begins a header: after the frame, or after its first byte when it is dropped
uint8_t from = 1;
if (frame_len <= N && handle_frame(static_cast<uint8_t>(frame_len))) {
from = frame_len;
} else {
on_drop();
}
pos = drop_to_next_header(buffer, total, from, data_header);
if (pos == 0) {
return;
}
}
}
#ifdef USE_SENSOR
template<typename T> using SensorWithDedup = sensor::SensorWithDedup<T>;
#endif
@@ -1,5 +1,6 @@
#include "download_buffer.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
namespace esphome::online_image {
@@ -34,6 +35,46 @@ size_t DownloadBuffer::read(size_t len) {
return this->unread_;
}
void DownloadSource::compact() {
if (this->keep_read_ || this->pos_ == this->start_)
return;
this->buffer_.read(this->pos_ - this->start_);
this->start_ = this->pos_;
}
int32_t DownloadSource::read(uint8_t *buf, uint32_t len) {
const size_t offset = this->pos_ - this->start_;
const size_t n = std::min<size_t>(len, this->buffer_.unread() - offset);
memcpy(buf, this->buffer_.data(offset), n);
this->pos_ += n;
return static_cast<int32_t>(n);
}
bool DownloadSource::seek(uint32_t position) {
// Only what is still in the buffer can be read again; a decoder never skips ahead of the download
if (position < this->start_ || position > this->start_ + this->buffer_.unread())
return false;
this->pos_ = position;
return true;
}
bool DownloadBuffer::grow(size_t size) {
if (this->size_ >= size)
return true;
RAMAllocator<uint8_t> allocator;
uint8_t *buffer = allocator.allocate(size);
if (buffer == nullptr) {
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", size,
allocator.get_max_free_block_size());
return false;
}
memcpy(buffer, this->buffer_, this->unread_);
allocator.deallocate(this->buffer_, this->size_);
this->buffer_ = buffer;
this->size_ = size;
return true;
}
size_t DownloadBuffer::resize(size_t size) {
if (this->size_ >= size) {
// Avoid useless reallocations; if the buffer is big enough, don't reallocate.
@@ -1,5 +1,6 @@
#pragma once
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
@@ -35,6 +36,8 @@ class DownloadBuffer {
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
/// Make the buffer at least size bytes, keeping the unread data. Returns false if the allocation failed.
bool grow(size_t size);
protected:
uint8_t *buffer_;
@@ -43,4 +46,35 @@ class DownloadBuffer {
size_t unread_;
};
/**
* @brief Lets a decoder read a download as it arrives, from the download buffer.
*
* The decoder reads at its own position in the buffer. Bytes it has read stay there until compact() drops
* them, and compact() keeps everything while set_keep_read(true) is in effect, so the decoder can seek back
* while it reads the headers.
*/
class DownloadSource : public runtime_image::DecodeSource {
public:
explicit DownloadSource(DownloadBuffer &buffer) : buffer_(buffer) {}
/// Start again at the beginning of a new download, keeping what is read
void reset() {
this->start_ = 0;
this->pos_ = 0;
this->keep_read_ = true;
}
void set_keep_read(bool keep) { this->keep_read_ = keep; }
/// Drop the bytes the decoder has read, to make room for more of the download
void compact();
int32_t read(uint8_t *buf, uint32_t len) override;
bool seek(uint32_t position) override;
protected:
DownloadBuffer &buffer_;
size_t start_{0}; // Position in the download of the first byte in the buffer
size_t pos_{0}; // Position in the download the decoder reads next
bool keep_read_{true};
};
} // namespace esphome::online_image
+12
View File
@@ -22,6 +22,8 @@ DEPENDENCIES = ["http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_ON_DOWNLOAD_STARTED = "on_download_started"
CONF_ON_DOWNLOAD_PROGRESS = "on_download_progress"
CONF_UPDATE = "update"
online_image_ns = cg.esphome_ns.namespace("online_image")
@@ -43,7 +45,9 @@ ONLINE_IMAGE_SCHEMA = (
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Optional(CONF_ON_DOWNLOAD_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
cv.Optional(CONF_ON_DOWNLOAD_PROGRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
}
)
@@ -96,9 +100,17 @@ automation.register_apply_action(
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_STARTED, "add_on_download_started_callback"
),
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
),
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_PROGRESS,
"add_on_progress_callback",
[(float, "progress"), (cg.size_t, "bytes_read")],
),
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
)
+144 -10
View File
@@ -4,6 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
ESPHOME_LOG_TAG(TAG, "online_image");
@@ -15,6 +16,11 @@ static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
namespace esphome::online_image {
// Time one loop pass may spend reading, and decoding a JPEG as it downloads
static constexpr uint32_t PASS_BUDGET_MS = 20;
// Shortest time between two on_download_progress calls
static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
uint32_t buffer_size, bool is_big_endian)
@@ -140,8 +146,12 @@ void OnlineImage::update() {
return;
}
// JPEG requires the complete image in the download buffer before decoding
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
// A JPEG of known size is decoded as it arrives, so the download buffer only has to hold its headers.
// Otherwise the decoder needs the complete image in the buffer.
this->streaming_ = total_size > 0 && total_size <= INT32_MAX && this->decoder_supports_source();
this->stream_started_ = false;
this->source_.reset();
if (!this->streaming_ && format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
this->download_buffer_.resize(total_size) == 0) {
// resize() logged the failed allocation; the next update() allocates the configured buffer again
this->end_connection_();
@@ -152,6 +162,8 @@ void OnlineImage::update() {
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = millis();
this->last_data_time_ = this->start_time_;
this->download_started_callback_.call();
this->report_progress_(true);
this->enable_loop();
}
@@ -169,6 +181,11 @@ void OnlineImage::loop() {
return;
}
if (this->streaming_) {
this->loop_stream_();
return;
}
// Check if download is complete — use decoder's format-specific completion check
// to handle both known content-length and chunked transfer encoding
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
@@ -179,15 +196,22 @@ void OnlineImage::loop() {
}
// Download and decode more data
size_t available = this->download_buffer_.free_capacity();
if (available > 0) {
// Download in chunks to avoid blocking
available = std::min(available, this->download_buffer_initial_size_);
auto len = this->downloader_->read(this->download_buffer_.append(), available);
if (len > 0) {
this->last_data_time_ = App.get_loop_component_start_time();
if (this->download_buffer_.free_capacity() > 0) {
const uint32_t now = App.get_loop_component_start_time();
size_t received = 0;
int len;
do {
len = this->downloader_->read(this->download_buffer_.append(),
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
if (len <= 0)
break;
this->download_buffer_.write(len);
received += len;
} while (this->download_buffer_.free_capacity() != 0 && millis() - now < PASS_BUDGET_MS);
if (received > 0) {
this->last_data_time_ = now;
this->report_progress_(false);
// Feed data to decoder
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
@@ -255,6 +279,114 @@ void OnlineImage::loop() {
}
}
void OnlineImage::loop_stream_() {
const uint32_t now = App.get_loop_component_start_time();
const size_t total = this->downloader_->content_length;
if (this->stream_started_)
this->source_.compact();
if (this->downloader_->get_bytes_read() < total) {
if (this->download_buffer_.free_capacity() == 0) {
// compact() already ran, so a full buffer means the decoder read nothing in the last pass
if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
this->download_buffer_.size());
this->fail_download_();
return;
}
} else {
int len;
do {
len = this->downloader_->read(this->download_buffer_.append(),
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
if (len <= 0)
break;
this->last_data_time_ = now;
this->download_buffer_.write(len);
this->report_progress_(false);
} while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
millis() - now < PASS_BUDGET_MS);
if (len < 0) {
ESP_LOGE(TAG, "Error downloading image: %d", len);
this->fail_download_();
return;
} else if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
this->fail_download_();
return;
}
}
}
const bool arrived = this->downloader_->get_bytes_read() >= total;
if (!this->stream_started_) {
// The decoder reads the headers when it opens the image, so wait until they are in the buffer
if (!arrived && this->download_buffer_.free_capacity() != 0)
return;
const int err = this->begin_source(&this->source_);
if (err != 0) {
// Headers cut short by a full buffer fail to parse, or make the decoder seek past the buffer. Such an image
// still decodes once it is all in memory, as it did before JPEG images were decoded as they download.
if (!arrived && (err == runtime_image::DECODE_ERROR_INVALID_TYPE || err == runtime_image::DECODE_ERROR_READ)) {
ESP_LOGW(TAG,
"The image headers do not fit the %zu byte download buffer, so the whole image is downloaded "
"before it is decoded; increase buffer_size to decode it as it downloads",
this->download_buffer_.size());
// grow() logs a failed allocation
if (this->download_buffer_.grow(total)) {
this->streaming_ = false;
} else {
this->fail_download_();
}
return;
}
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(err));
this->fail_download_();
return;
}
this->source_.set_keep_read(false);
this->stream_started_ = true;
// Opening the image and allocating its buffer is enough for one pass
return;
}
do {
const int result = this->decode_step();
if (result == runtime_image::DECODE_STEP_MORE)
continue;
if (result == runtime_image::DECODE_STEP_DONE) {
this->finish_download_();
} else if (result == runtime_image::DECODE_STEP_NEED_DATA) {
if (!arrived)
return;
// JPEGDEC only waits for bytes short of the size it was given, so this guards against a hang
ESP_LOGE(TAG, "Download ended before the image was complete");
this->fail_download_();
} else {
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(result));
this->fail_download_();
}
return;
} while (millis() - now < PASS_BUDGET_MS);
}
void OnlineImage::report_progress_(bool force) {
const uint32_t now = App.get_loop_component_start_time();
const size_t bytes = this->downloader_->get_bytes_read();
const size_t total = this->downloader_->content_length;
// The last bytes are always reported, so a progress bar reaches 100%
if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
return;
this->last_progress_time_ = now;
const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
this->download_progress_callback_.call(progress, bytes);
}
void OnlineImage::fail_download_() {
this->end_connection_();
this->download_error_callback_.call();
}
void OnlineImage::finish_download_() {
// Must be read before end_decode() ends the decoding session
bool decoder_finished = this->is_decode_finished();
@@ -288,6 +420,8 @@ void OnlineImage::end_connection_() {
this->downloader_ = nullptr;
}
this->download_buffer_.reset();
this->streaming_ = false;
this->stream_started_ = false;
this->disable_loop();
}
@@ -16,6 +16,10 @@ using t_http_codes = enum {
HTTP_CODE_NOT_FOUND = 404,
};
/// Largest download read(). A read() blocks until it has every byte it asked for, so this bounds how long one loop
/// pass waits on a slow link.
static constexpr size_t READ_CHUNK = 1460;
/**
* @brief Download an image from a given URL, and decode it using the specified decoder.
* The image will then be stored in a buffer, so that it can be re-displayed without the
@@ -70,9 +74,17 @@ class OnlineImage final : public PollingComponent,
*/
void release();
/// Called when the server has accepted the request and the image starts to download
template<typename F> void add_on_download_started_callback(F &&callback) {
this->download_started_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_finished_callback(F &&callback) {
this->download_finished_callback_.add(std::forward<F>(callback));
}
/// Called with the percentage downloaded (NAN when the server did not send the size) and the bytes read so far
template<typename F> void add_on_progress_callback(F &&callback) {
this->download_progress_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_error_callback(F &&callback) {
this->download_error_callback_.add(std::forward<F>(callback));
}
@@ -81,12 +93,20 @@ class OnlineImage final : public PollingComponent,
bool validate_url_(const std::string &url);
void end_connection_();
void finish_download_();
/// loop() for a JPEG that is decoded as it downloads
void loop_stream_();
void fail_download_();
/// Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set
void report_progress_(bool force);
CallbackManager<void(bool)> download_finished_callback_{};
CallbackManager<void()> download_error_callback_{};
LazyCallbackManager<void()> download_started_callback_{};
LazyCallbackManager<void(float, size_t)> download_progress_callback_{};
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
DownloadBuffer download_buffer_;
DownloadSource source_{download_buffer_};
/**
* This is the *initial* size of the download buffer, not the current size.
* The download buffer can be resized at runtime; the download_buffer_initial_size_
@@ -109,6 +129,9 @@ class OnlineImage final : public PollingComponent,
uint32_t start_time_{0};
uint32_t last_data_time_{0};
uint32_t last_progress_time_{0};
bool streaming_{false}; // The decoder reads the download itself, as it arrives
bool stream_started_{false}; // The decoder has read the headers
};
} // namespace esphome::online_image
@@ -2,7 +2,6 @@
#include "runtime_image.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::runtime_image {
@@ -11,8 +10,8 @@ ESPHOME_LOG_TAG(TAG, "image_decoder");
bool ImageDecoder::set_size(int width, int height) {
bool success = this->image_->resize(width, height) > 0;
this->size_valid_ = success;
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
this->draw_width_ = width;
this->draw_height_ = height;
return success;
}
@@ -20,10 +19,24 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
if (!this->size_valid_) {
return;
}
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
for (int i = x * this->x_scale_; i < width; i++) {
for (int j = y * this->y_scale_; j < height; j++) {
// When shrinking, each target pixel is painted once, by the first source pixel that reaches it, rather than by every
// pixel that lands on it. Integer maths: the S3 has no double FPU.
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
int x1 = x;
int y1 = y;
int x2 = x + w;
int y2 = y + h;
if (buffer_width != this->draw_width_ || buffer_height != this->draw_height_) {
x1 = scale_boundary(x, this->draw_width_, buffer_width);
y1 = scale_boundary(y, this->draw_height_, buffer_height);
x2 = scale_boundary(x + w, this->draw_width_, buffer_width);
y2 = scale_boundary(y + h, this->draw_height_, buffer_height);
}
x2 = std::min(buffer_width, x2);
y2 = std::min(buffer_height, y2);
for (int i = x1; i < x2; i++) {
for (int j = y1; j < y2; j++) {
this->image_->draw_pixel(i, j, color);
}
}
@@ -6,6 +6,15 @@
namespace esphome::runtime_image {
/// The first of `target` pixels that pixel n of `source` paints, rounded up: ceil(n * target / source). The spans of
/// neighbouring pixels meet without overlapping, so a shrunk image paints each target pixel once.
inline int scale_boundary(int n, int source, int target) {
if (source <= 0)
return 0;
return static_cast<int>((static_cast<uint32_t>(n) * static_cast<uint32_t>(target) + source - 1) /
static_cast<uint32_t>(source));
}
enum DecodeError : int {
DECODE_ERROR_INVALID_TYPE = -1,
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
@@ -83,8 +92,8 @@ class ImageDecoder {
this->expected_size_ = 0;
this->decoded_bytes_ = 0;
this->size_valid_ = true;
this->x_scale_ = 1.0;
this->y_scale_ = 1.0;
this->draw_width_ = 0;
this->draw_height_ = 0;
}
/**
@@ -184,8 +193,9 @@ class ImageDecoder {
uint8_t *image_buffer_() const;
bool image_is_big_endian_() const;
double x_scale_ = 1.0;
double y_scale_ = 1.0;
// Size of the image as the decoder draws it, which draw() spreads over the buffer
int draw_width_ = 0;
int draw_height_ = 0;
RuntimeImage *image_;
size_t expected_size_ = 0; // Expected data size (0 if unknown)
size_t decoded_bytes_ = 0; // Bytes processed so far
@@ -28,16 +28,14 @@ bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
this->source_height_ = height;
this->x_boundaries_.clear();
this->y_boundaries_.clear();
const uint32_t target_width = this->image_->get_buffer_width();
const uint32_t target_height = this->image_->get_buffer_height();
// Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6;
// the generic draw() path rounds down and keeps the odd ones instead
for (uint32_t x = 0; x <= static_cast<uint32_t>(width); x++) {
this->x_boundaries_.push_back(static_cast<uint16_t>((x * target_width + width - 1) / width));
const int target_width = this->image_->get_buffer_width();
const int target_height = this->image_->get_buffer_height();
// The same boundaries as the generic draw() path, so an 8 to 4 scale keeps columns 0, 2, 4 and 6 in both
for (int x = 0; x <= width; x++) {
this->x_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(x, width, target_width)));
}
for (uint32_t y = 0; y <= static_cast<uint32_t>(height); y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>((y * target_height + height - 1) / height));
for (int y = 0; y <= height; y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(y, height, target_height)));
}
return true;
}
@@ -138,7 +136,7 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_.decode(0, 0, 0))
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
@@ -165,10 +163,28 @@ int JpegDecoder::start_() {
} else {
this->jpeg_.setPixelType(RGB8888);
}
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
if (!this->set_size(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
// many pixels as the buffer, so a large image shown small is not decoded in full only to be thrown away.
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
this->scale_ = 1;
while (this->scale_ < JPEG_SCALE_EIGHTH && width / (this->scale_ * 2) >= buffer_width &&
height / (this->scale_ * 2) >= buffer_height) {
this->scale_ *= 2;
}
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
this->draw_width_ = decoded_width;
this->draw_height_ = decoded_height;
if (this->scale_ != 1) {
ESP_LOGD(TAG, "Decoding at 1/%d size, %d x %d", this->scale_, decoded_width, decoded_height);
}
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(decoded_width, decoded_height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
return 0;
@@ -252,7 +268,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, 0))
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
@@ -31,12 +31,16 @@ class JpegDecoder : public ImageDecoder {
int HOT decode_step() override;
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
/// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8
int get_scale() const { return this->scale_; }
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
protected:
bool prepare_rgb565_scaling_(int width, int height);
/// Set up an opened image for decoding. Returns 0, or a DecodeError.
int start_();
/// The JPEGDEC option for scale_: JPEG_SCALE_HALF, _QUARTER and _EIGHTH are the divisors themselves
int scale_option_() const { return this->scale_ == 1 ? 0 : this->scale_; }
/// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image
int fail_();
@@ -49,6 +53,7 @@ class JpegDecoder : public ImageDecoder {
FixedVector<uint16_t> y_boundaries_{};
int source_width_{0};
int source_height_{0};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool direct_rgb565_{false};
bool source_failed_{false};
@@ -1,152 +0,0 @@
#include <cstdint>
#include <cstring>
#include <vector>
#include <gtest/gtest.h>
#include "esphome/components/ld24xx/ld24xx.h"
namespace esphome::ld24xx::testing {
namespace {
constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0xF2, 0xF1};
constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5};
constexpr uint8_t DATA_FRAME_SIZE = 12; // header, four payload bytes, footer
constexpr size_t BUFFER_SIZE = 40;
// Command frame around the payload
std::vector<uint8_t> make_cmd_frame(const std::vector<uint8_t> &payload) {
std::vector<uint8_t> frame(std::begin(CMD_FRAME_HEADER), std::end(CMD_FRAME_HEADER));
frame.push_back(payload.size());
frame.push_back(0x00);
frame.insert(frame.end(), payload.begin(), payload.end());
frame.insert(frame.end(), std::begin(CMD_FRAME_FOOTER), std::end(CMD_FRAME_FOOTER));
return frame;
}
std::vector<uint8_t> make_data_frame(uint8_t fill) {
std::vector<uint8_t> frame(std::begin(DATA_FRAME_HEADER), std::end(DATA_FRAME_HEADER));
frame.insert(frame.end(), DATA_FRAME_SIZE - 2 * HEADER_FOOTER_SIZE, fill);
frame.insert(frame.end(), std::begin(DATA_FRAME_FOOTER), std::end(DATA_FRAME_FOOTER));
return frame;
}
} // namespace
// Data frames are fixed size, command frames carry their count, as in the radar components
class LD24xxFrameSplitterTest : public ::testing::Test {
protected:
void feed_(const std::vector<uint8_t> &bytes) {
for (uint8_t byte : bytes) {
feed_frame_byte(
this->buffer_, this->pos_, byte, DATA_FRAME_HEADER,
[](const uint8_t *buffer, uint8_t count) -> uint32_t {
if (count < HEADER_FOOTER_SIZE) {
return 0;
}
return buffer[0] == DATA_FRAME_HEADER[0] ? DATA_FRAME_SIZE : cmd_frame_length(buffer, count);
},
[this](uint8_t frame_len) { return this->handle_frame_(frame_len); }, [this]() { this->drops_++; });
}
}
bool handle_frame_(uint8_t frame_len) {
const uint8_t *footer = this->buffer_[0] == DATA_FRAME_HEADER[0] ? DATA_FRAME_FOOTER : CMD_FRAME_FOOTER;
if (!validate_header_footer(footer, &this->buffer_[frame_len - HEADER_FOOTER_SIZE])) {
return false;
}
this->frames_.emplace_back(this->buffer_, this->buffer_ + frame_len);
return true;
}
uint8_t buffer_[BUFFER_SIZE];
uint8_t pos_{0};
std::vector<std::vector<uint8_t>> frames_;
int drops_{0};
};
TEST_F(LD24xxFrameSplitterTest, FooterBytesInThePayloadDoNotEndTheFrame) {
auto frame = make_cmd_frame({0x61, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, 0x55});
this->feed_(frame);
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], frame);
EXPECT_EQ(this->pos_, 0);
EXPECT_EQ(this->drops_, 0);
}
TEST_F(LD24xxFrameSplitterTest, BytesBeforeAHeaderAreNeverBuffered) {
this->feed_({0x00, 0x11, 0xFC, 0xFB, 0xFA, 0x04});
EXPECT_EQ(this->pos_, 0);
this->feed_(make_data_frame(0x22));
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_data_frame(0x22));
}
TEST_F(LD24xxFrameSplitterTest, AHeaderRestartsWhenItsBytesStopMatching) {
// F3 cannot continue the command header, and FD after FC starts it over
this->feed_({0xFD, 0xF3, 0xFD, 0xFC, 0xFD, 0xFC, 0xFB, 0xFA, 0x00, 0x00});
EXPECT_EQ(this->pos_, 6) << "only the header that matched through and its count are buffered";
this->feed_({0x04, 0x03, 0x02, 0x01});
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_cmd_frame({}));
EXPECT_EQ(this->drops_, 0);
}
TEST_F(LD24xxFrameSplitterTest, ImpossibleCountsAreDroppedAndTheNextFrameParsed) {
// 0xFFF6 totals 65536, which must not wrap to 0
this->feed_({0xFD, 0xFC, 0xFB, 0xFA, 0xF6, 0xFF});
this->feed_({0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0xFF});
EXPECT_EQ(this->drops_, 2);
EXPECT_EQ(this->pos_, 0);
this->feed_(make_data_frame(0x33));
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_data_frame(0x33));
}
TEST_F(LD24xxFrameSplitterTest, AFrameCaughtInsideADroppedFrameIsReplayed) {
// The count reaches 20 bytes in, past the whole frame that follows
std::vector<uint8_t> bytes = {0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x00, 0x61, 0x01};
auto inner = make_data_frame(0x44);
bytes.insert(bytes.end(), inner.begin(), inner.end());
this->feed_(bytes);
EXPECT_EQ(this->drops_, 1);
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], inner);
EXPECT_EQ(this->pos_, 0);
}
TEST_F(LD24xxFrameSplitterTest, EveryWholeFrameLeftByADropIsDrained) {
// The window holds two whole data frames and the start of a third
std::vector<uint8_t> bytes = {0xFD, 0xFC, 0xFB, 0xFA, 0x18, 0x00, 0x61, 0x01};
for (uint8_t fill : {0x55, 0x66}) {
auto inner = make_data_frame(fill);
bytes.insert(bytes.end(), inner.begin(), inner.end());
}
bytes.insert(bytes.end(), {0xF4, 0xF3});
this->feed_(bytes);
EXPECT_EQ(this->drops_, 1);
ASSERT_EQ(this->frames_.size(), 2u);
EXPECT_EQ(this->frames_[0], make_data_frame(0x55));
EXPECT_EQ(this->frames_[1], make_data_frame(0x66));
EXPECT_EQ(this->pos_, 2) << "the partial header at the end stays buffered";
}
TEST_F(LD24xxFrameSplitterTest, AWrongFooterDropsOnlyThatFrame) {
auto bad = make_cmd_frame({0x01, 0x02});
bad[bad.size() - 1] = 0xEE;
this->feed_(bad);
EXPECT_EQ(this->drops_, 1);
EXPECT_TRUE(this->frames_.empty());
this->feed_(make_cmd_frame({0x03}));
ASSERT_EQ(this->frames_.size(), 1u);
EXPECT_EQ(this->frames_[0], make_cmd_frame({0x03}));
}
TEST_F(LD24xxFrameSplitterTest, BackToBackFramesOfBothKinds) {
this->feed_(make_data_frame(0x01));
this->feed_(make_cmd_frame({0xA0, 0x01, 0x00, 0x00}));
this->feed_(make_data_frame(0x02));
ASSERT_EQ(this->frames_.size(), 3u);
EXPECT_EQ(this->frames_[1], make_cmd_frame({0xA0, 0x01, 0x00, 0x00}));
EXPECT_EQ(this->frames_[2], make_data_frame(0x02));
EXPECT_EQ(this->drops_, 0);
}
} // namespace esphome::ld24xx::testing
@@ -19,6 +19,11 @@ image:
} else {
ESP_LOGD("online_image", "Cache miss: fresh download");
}
on_download_started:
- logger.log: Download started
on_download_progress:
lambda: |-
ESP_LOGD("online_image", "Downloaded %.0f%% (%zu bytes)", progress, bytes_read);
- platform: online_image
id: online_binary_transparent_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
@@ -2,6 +2,7 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <memory>
@@ -27,6 +28,9 @@ class FakeContainer : public http_request::HttpContainer {
}
int read(uint8_t *buf, size_t max_len) override {
this->largest_read = std::max(this->largest_read, max_len);
if (this->read_error_)
return -1;
size_t len = std::min(max_len, this->available_ - this->bytes_read_);
std::memcpy(buf, this->body_.data() + this->bytes_read_, len);
this->bytes_read_ += len;
@@ -42,13 +46,21 @@ class FakeContainer : public http_request::HttpContainer {
}
void set_content_length(size_t length) { this->content_length = length; }
/// Make every read from now on fail, like a dropped connection
void fail_reads() { this->read_error_ = true; }
/// Let len more bytes of the body arrive
void deliver(size_t len) { this->available_ = std::min(this->body_.size(), this->available_ + len); }
void add_response_header(const std::string &name, const std::string &value) {
this->response_headers_.push_back({name, value});
}
/// The most bytes any one read() asked for
size_t largest_read{0};
protected:
std::vector<uint8_t> body_;
size_t available_;
bool read_error_{false};
};
class FakeHttpRequest : public http_request::HttpRequestComponent {
@@ -123,13 +135,173 @@ static constexpr std::array<uint8_t, 69> PNG_1X1{
0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9,
0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82};
// 8x8 progressive JPEG, 521 bytes
static const uint8_t JPEG_PROGRESSIVE[] = {
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10,
0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33,
0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67,
0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11,
0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00,
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x15, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xFF, 0xC4, 0x00, 0x15, 0x01, 0x01, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x05, 0xFF, 0xDA, 0x00, 0x0C, 0x03,
0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0x94, 0x05, 0x3F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08,
0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F,
0x01, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0x7F, 0xFF, 0xC4, 0x00,
0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01,
0x01, 0x00, 0x01, 0x3F, 0x21, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00,
0x10, 0x0B, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00,
0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01,
0x01, 0x00, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xD9,
};
// 48x40 4:2:0 JPEG (quality 95), 2344 bytes: three rows of 16 pixel MCUs, with the scan starting at byte 609
static const uint8_t JPEG_48X40[] = {
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02,
0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05,
0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A,
0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x02,
0x02, 0x02, 0x02, 0x02, 0x02, 0x05, 0x03, 0x03, 0x05, 0x0A, 0x07, 0x06, 0x07, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A,
0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x28, 0x00, 0x30, 0x03, 0x01, 0x22, 0x00,
0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00,
0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62,
0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A,
0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85,
0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6,
0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7,
0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7,
0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00,
0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03,
0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09,
0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A,
0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A,
0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96,
0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7,
0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8,
0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9,
0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xFC, 0xAA, 0xB8, 0xB7,
0xD3, 0xB5, 0xD8, 0x1E, 0xD6, 0xD1, 0xA5, 0x5B, 0x4B, 0x46, 0x10, 0xC1, 0x06, 0xA1, 0x29, 0x69, 0x25, 0xB7, 0xC2,
0xC6, 0x0C, 0x71, 0x26, 0x5E, 0x50, 0x15, 0x5D, 0xB6, 0x9C, 0x9E, 0x00, 0xC1, 0x67, 0x67, 0x4D, 0xCF, 0x84, 0xF7,
0x57, 0xA6, 0xDA, 0x08, 0x52, 0xF5, 0xE5, 0xBA, 0xB8, 0x7B, 0x5B, 0x68, 0xEE, 0x2F, 0x66, 0x89, 0xB7, 0xB7, 0x9B,
0xE5, 0x9C, 0x23, 0xAA, 0xFE, 0xF7, 0x68, 0x11, 0x9E, 0x5D, 0x18, 0x12, 0x48, 0x56, 0x5C, 0x9F, 0x57, 0xB6, 0xF8,
0x33, 0xF0, 0xE2, 0x5B, 0xFB, 0xCF, 0xF4, 0x51, 0x0C, 0xC6, 0x64, 0x30, 0x49, 0x71, 0x7C, 0xE2, 0x16, 0x90, 0x2E,
0xF4, 0x72, 0xEA, 0xE0, 0x34, 0x81, 0x77, 0x93, 0x9C, 0x07, 0xC2, 0x93, 0xB9, 0x61, 0xC0, 0xD3, 0xF0, 0x17, 0xC1,
0xBF, 0x03, 0xCD, 0xE1, 0xAD, 0x42, 0x3F, 0x0A, 0xD8, 0xC7, 0x2D, 0xAC, 0x36, 0xCB, 0xF6, 0x79, 0x22, 0x69, 0x84,
0xC4, 0x93, 0x29, 0x2A, 0x89, 0xB9, 0x00, 0x50, 0x62, 0x7C, 0xFF, 0x00, 0x16, 0xD9, 0x65, 0x00, 0x16, 0x65, 0x07,
0xFA, 0x5B, 0x36, 0xE2, 0xAC, 0xBF, 0x2D, 0x55, 0x79, 0x29, 0xB5, 0x26, 0x9B, 0xA9, 0x18, 0xB5, 0x15, 0x36, 0x9B,
0x53, 0xD1, 0xCA, 0x10, 0x93, 0x69, 0xB6, 0x94, 0x27, 0x16, 0xAF, 0x19, 0x29, 0xB8, 0xBB, 0x3F, 0x9A, 0xE1, 0x1E,
0x2A, 0xCB, 0x9E, 0x79, 0x4A, 0x92, 0x84, 0xDA, 0xD6, 0xED, 0xA4, 0xED, 0x7A, 0x72, 0xB5, 0xAC, 0xF6, 0xEB, 0xAD,
0xD5, 0x97, 0x91, 0xC9, 0x4D, 0xA4, 0xE9, 0xD7, 0x56, 0x45, 0x66, 0xD3, 0x9D, 0x85, 0xB1, 0x86, 0x59, 0x7E, 0xD9,
0x7E, 0xA0, 0xE0, 0xA8, 0x02, 0x17, 0x02, 0x3C, 0x36, 0x59, 0x18, 0xE2, 0x42, 0x1D, 0x40, 0x56, 0x0A, 0x77, 0x30,
0xAE, 0xFA, 0x56, 0xD4, 0xA4, 0x8A, 0xD9, 0xAD, 0xD6, 0x38, 0x1D, 0x7F, 0x77, 0x77, 0x1C, 0xB2, 0x4C, 0x22, 0x8C,
0x2B, 0xF3, 0x14, 0x64, 0x45, 0x85, 0x52, 0x47, 0x92, 0x7E, 0xF2, 0x0F, 0x34, 0x1D, 0xBC, 0x56, 0xDE, 0x85, 0xF0,
0x4B, 0xE1, 0x0A, 0xE9, 0xB0, 0xDB, 0x2D, 0x8C, 0x4C, 0xCD, 0x74, 0xF1, 0x42, 0x96, 0x93, 0xCF, 0xF6, 0x54, 0x0C,
0xB3, 0x44, 0x03, 0xAA, 0x6C, 0x49, 0x10, 0x87, 0x00, 0x88, 0xC2, 0xEE, 0x66, 0x6F, 0x9B, 0xAB, 0x57, 0xA4, 0x68,
0xDF, 0x0A, 0x3E, 0x1B, 0xDA, 0xE9, 0xDF, 0xD9, 0x96, 0xFE, 0x03, 0xB2, 0x8E, 0xD9, 0x8A, 0xDD, 0xC1, 0x77, 0x14,
0x97, 0x50, 0x2E, 0xE3, 0x20, 0x62, 0xA0, 0x33, 0x0C, 0xC6, 0x58, 0x48, 0x36, 0x6D, 0xC1, 0x20, 0xA2, 0x2B, 0x00,
0xB5, 0xF1, 0x99, 0xCF, 0x19, 0x65, 0x79, 0xAD, 0x0C, 0x3C, 0x2B, 0x61, 0xF9, 0x28, 0xD6, 0x52, 0x97, 0x35, 0x95,
0xD4, 0x61, 0x28, 0x5B, 0x99, 0xB8, 0xA9, 0xA9, 0x47, 0xD9, 0x3A, 0x69, 0xA9, 0xC6, 0x51, 0x52, 0x6A, 0x0D, 0x4A,
0x74, 0xA9, 0xC3, 0xFA, 0x7F, 0xC3, 0xFE, 0x25, 0xC1, 0x4E, 0xAE, 0x26, 0x2E, 0x13, 0x71, 0x5C, 0x8D, 0xFB, 0xB1,
0xD6, 0xEA, 0x6F, 0xF9, 0xAF, 0xBE, 0xB7, 0x4B, 0x55, 0x1D, 0x75, 0x4C, 0xF3, 0xDF, 0x05, 0xD9, 0xF8, 0x7B, 0xC3,
0xBA, 0xB5, 0xF1, 0xBC, 0x8D, 0x63, 0xDF, 0x1C, 0x32, 0x88, 0x1A, 0xF3, 0x74, 0x73, 0x89, 0x15, 0xC4, 0x8A, 0x25,
0x66, 0xDC, 0xAA, 0x98, 0x60, 0x06, 0x7F, 0xBD, 0xF2, 0x81, 0xF3, 0x36, 0xFE, 0x9D, 0xAF, 0x78, 0x8F, 0x54, 0xD3,
0xAD, 0xA6, 0x9E, 0xC5, 0xE5, 0x9C, 0x96, 0xDC, 0xB7, 0xE8, 0x84, 0x47, 0xFB, 0xB4, 0x54, 0x42, 0x0F, 0x03, 0xF8,
0xD7, 0xE5, 0x39, 0x60, 0xF8, 0x04, 0x79, 0x7B, 0xE3, 0xF4, 0x9F, 0x85, 0x5F, 0x08, 0xBE, 0x0D, 0x2C, 0x57, 0xCE,
0x6C, 0xA5, 0x2B, 0xA8, 0x08, 0x20, 0x82, 0x04, 0xB9, 0xB8, 0x82, 0x43, 0x28, 0x72, 0x55, 0xD5, 0x83, 0xB9, 0x05,
0xC2, 0x44, 0x55, 0x77, 0x8C, 0xFC, 0xEC, 0xD8, 0xCB, 0xE7, 0xBE, 0x9B, 0xE0, 0x5F, 0xC3, 0x9D, 0x32, 0xD4, 0x45,
0x26, 0x8B, 0x2C, 0x93, 0xC0, 0xF3, 0x95, 0x49, 0xEE, 0x2E, 0x0C, 0x26, 0x26, 0x31, 0xC9, 0xF3, 0xED, 0x95, 0x96,
0x32, 0xEC, 0x10, 0xE1, 0xB8, 0x60, 0x3A, 0x90, 0x1B, 0x3F, 0x9E, 0x66, 0x58, 0xFC, 0x14, 0x31, 0x15, 0xFD, 0x9C,
0x79, 0x57, 0x3A, 0x92, 0x84, 0x1C, 0x3D, 0xC9, 0xCD, 0x27, 0x6D, 0x12, 0xE5, 0xE7, 0xA6, 0xD5, 0x4D, 0x64, 0x93,
0xD1, 0x45, 0xC6, 0xAD, 0xE7, 0x2F, 0xE9, 0xEE, 0x17, 0xE3, 0x0C, 0x0A, 0xE1, 0x9C, 0x37, 0x34, 0x67, 0x2B, 0x73,
0x2B, 0xB4, 0xBA, 0x49, 0xF4, 0x72, 0x57, 0x51, 0xDD, 0x24, 0x9B, 0x5A, 0x36, 0x9A, 0x49, 0x1E, 0x15, 0xA7, 0x7E,
0xCE, 0xF6, 0x89, 0x6A, 0x96, 0x43, 0x55, 0xB5, 0xB3, 0xB6, 0xB8, 0x96, 0x2B, 0x9B, 0x78, 0xA0, 0xB4, 0x9E, 0xE5,
0x61, 0x90, 0xE0, 0xC2, 0xEC, 0x61, 0x91, 0x47, 0xCC, 0xDB, 0xF1, 0x80, 0xC0, 0x98, 0xD4, 0xE5, 0xDC, 0x64, 0xEC,
0x7C, 0x29, 0xFD, 0x98, 0x6C, 0xAF, 0xEC, 0xF5, 0x76, 0xB7, 0xF1, 0x36, 0x9A, 0x5D, 0x6F, 0x96, 0xE1, 0x74, 0xB5,
0xD3, 0x44, 0x91, 0xA8, 0x45, 0x0C, 0xE2, 0x3D, 0xAC, 0x85, 0xF9, 0x3B, 0x5B, 0x19, 0x8A, 0x43, 0x1F, 0xCA, 0x50,
0x9D, 0x87, 0xD2, 0x0F, 0xC2, 0x1F, 0x89, 0x4F, 0x6D, 0x04, 0x0F, 0xE1, 0x61, 0xAA, 0xEE, 0xB4, 0x8E, 0x56, 0xFB,
0x06, 0xAF, 0x04, 0xF3, 0x40, 0x9B, 0x55, 0x4E, 0xCB, 0x66, 0x91, 0xCB, 0x18, 0xC2, 0x88, 0xB6, 0xB1, 0x65, 0x60,
0xA4, 0x6E, 0xDC, 0xA4, 0x9E, 0xAF, 0xE1, 0xBF, 0xC2, 0x2F, 0x89, 0xA9, 0xE1, 0xFB, 0x8D, 0x6E, 0xC2, 0x3B, 0x4B,
0x30, 0xD7, 0x60, 0xDA, 0x41, 0x2D, 0xCC, 0x6C, 0x2E, 0x49, 0xF3, 0x1E, 0x42, 0x19, 0x5D, 0x58, 0x3C, 0x8A, 0x81,
0x82, 0xB8, 0x0C, 0xC9, 0xF2, 0xE5, 0x8F, 0x03, 0x8F, 0x36, 0xE3, 0x8A, 0x98, 0xCC, 0x14, 0xAB, 0x62, 0x2B, 0xC2,
0x34, 0xA2, 0xA5, 0x78, 0x37, 0x42, 0x12, 0x70, 0x8C, 0x62, 0xD2, 0x6D, 0x72, 0x7B, 0xAF, 0x9B, 0xDD, 0xFD, 0xE5,
0x4E, 0x79, 0x49, 0x45, 0x73, 0xA9, 0x72, 0x2F, 0xF0, 0x73, 0x83, 0xF8, 0xCE, 0xB4, 0x33, 0xE8, 0x3F, 0xAE, 0x42,
0xF7, 0x76, 0xB3, 0x83, 0x57, 0xE5, 0x95, 0xBE, 0xCE, 0x8F, 0x7B, 0xDE, 0xD7, 0x4B, 0x6B, 0x9C, 0x1E, 0x8D, 0xF0,
0x56, 0x7B, 0xDD, 0x26, 0x34, 0x5F, 0x1F, 0xDB, 0x9D, 0x37, 0x53, 0xB8, 0x96, 0xED, 0xA0, 0x16, 0xB1, 0xC9, 0x2C,
0x8B, 0xF2, 0x26, 0x58, 0x89, 0x37, 0xEE, 0x04, 0xB2, 0x97, 0x54, 0x2D, 0x88, 0xC0, 0x60, 0x0B, 0x03, 0x5E, 0x9B,
0x67, 0xFB, 0x36, 0xE9, 0x31, 0x2D, 0x9C, 0x37, 0x9E, 0x32, 0x84, 0x5D, 0x2C, 0x4A, 0xD6, 0xAD, 0x73, 0x63, 0x28,
0x36, 0xAA, 0x53, 0xF7, 0x90, 0x29, 0x8E, 0x5C, 0x92, 0xBE, 0x41, 0xDC, 0xC0, 0xB3, 0x2B, 0x46, 0x32, 0x4B, 0x96,
0x35, 0xAD, 0x1F, 0xC2, 0xDF, 0x89, 0x17, 0xF6, 0xB6, 0xFA, 0xB5, 0xED, 0xB8, 0xB7, 0x8E, 0x78, 0x66, 0x96, 0x09,
0x6C, 0x6E, 0x23, 0x32, 0x28, 0x08, 0xE8, 0xF2, 0xBA, 0xF9, 0x86, 0x48, 0xC0, 0x68, 0xFE, 0x63, 0xC8, 0x23, 0x82,
0xCC, 0xA5, 0x4A, 0xFA, 0x66, 0x8F, 0xF0, 0x37, 0xE2, 0x6D, 0xE5, 0xC9, 0x3A, 0x87, 0x86, 0xB4, 0xFB, 0x28, 0xAE,
0x61, 0x0F, 0x0B, 0x59, 0xDF, 0xC0, 0xD1, 0xAC, 0xE0, 0x24, 0x41, 0x42, 0xC4, 0x49, 0x1B, 0x10, 0x21, 0x50, 0xC0,
0x09, 0x11, 0x8B, 0xEE, 0x6E, 0x71, 0xE2, 0xE2, 0x78, 0xBB, 0x33, 0xFA, 0xC5, 0x1A, 0xB4, 0xEA, 0xC6, 0x74, 0xD2,
0x8C, 0x24, 0xA5, 0x2A, 0x3C, 0xB5, 0x2E, 0xB9, 0x9B, 0x71, 0x97, 0x34, 0xE0, 0xAA, 0x54, 0x93, 0x71, 0x56, 0x49,
0xB9, 0xC6, 0x4E, 0x9A, 0x9B, 0xD3, 0xFA, 0x7F, 0x81, 0xB8, 0xD6, 0xAC, 0xDD, 0x6F, 0x65, 0x8D, 0x82, 0x77, 0x85,
0x95, 0xE1, 0x7D, 0x1C, 0xD2, 0x8B, 0x7A, 0x27, 0xA2, 0xF4, 0xED, 0xEE, 0x9E, 0x67, 0xE0, 0x8F, 0x81, 0x37, 0xBA,
0xEF, 0x87, 0x67, 0xBF, 0x8F, 0xC4, 0xD2, 0xDD, 0x4F, 0x05, 0xA8, 0x61, 0x7B, 0x1E, 0x9E, 0xE1, 0x8C, 0xEA, 0xF3,
0x18, 0x99, 0xA3, 0xDE, 0x76, 0x66, 0x36, 0x2A, 0x30, 0xFB, 0x9B, 0x0F, 0xC3, 0x1C, 0xA3, 0x76, 0x4B, 0xFB, 0x3B,
0xE8, 0xBA, 0x66, 0xBF, 0x0E, 0x93, 0xFD, 0xAB, 0x23, 0xD9, 0xA5, 0xCE, 0x62, 0xF3, 0x34, 0xEB, 0x7F, 0x29, 0x0A,
0xA7, 0x04, 0x48, 0xF2, 0x79, 0x82, 0x42, 0x1D, 0x46, 0x4B, 0xA9, 0x1B, 0xD1, 0xBE, 0x52, 0x54, 0xD7, 0xA8, 0xF8,
0x33, 0xE1, 0xAF, 0xC4, 0xA8, 0x24, 0xD6, 0x6D, 0xED, 0xBC, 0x0A, 0xD3, 0x5A, 0x5C, 0xC4, 0xDF, 0x64, 0x17, 0x97,
0x36, 0xF0, 0x65, 0x52, 0x39, 0xD4, 0x23, 0x22, 0x9D, 0xD8, 0x64, 0x83, 0x04, 0x85, 0x38, 0xEF, 0xB8, 0x49, 0x91,
0xBB, 0xFF, 0x00, 0x0A, 0xDF, 0xE3, 0x77, 0x84, 0xAC, 0xD3, 0x4A, 0xBC, 0xF0, 0x3A, 0x5A, 0x5B, 0x4F, 0x72, 0x5E,
0xD7, 0x53, 0x8E, 0xE2, 0x12, 0x6D, 0xC6, 0x40, 0x20, 0x47, 0xE6, 0x3E, 0x71, 0x26, 0x70, 0x24, 0xE4, 0xB4, 0x63,
0x2A, 0xC0, 0xE0, 0x7E, 0x71, 0x98, 0x71, 0x35, 0x5C, 0x4E, 0x69, 0x5A, 0xBD, 0x7C, 0x7A, 0x9C, 0xE3, 0xC9, 0xCF,
0x38, 0x46, 0x8D, 0xA4, 0xDC, 0x63, 0x38, 0x4A, 0xE9, 0xED, 0x5A, 0x4E, 0x4A, 0x6D, 0xF3, 0xCA, 0x2D, 0xB9, 0x4D,
0xD3, 0x77, 0x8C, 0x7F, 0xA8, 0xF8, 0x53, 0x8D, 0x31, 0x32, 0xC8, 0xA9, 0xDB, 0x15, 0x04, 0x9F, 0xB4, 0xD2, 0xF4,
0xD3, 0xF8, 0xA5, 0x77, 0xCD, 0xB3, 0x49, 0x6C, 0xB4, 0xEA, 0x97, 0x46, 0x78, 0x45, 0xC4, 0x16, 0x5A, 0xDD, 0xA5,
0xA6, 0x8D, 0xA7, 0xEA, 0x77, 0x4A, 0x54, 0x47, 0x3C, 0x31, 0x41, 0x20, 0x06, 0x68, 0x00, 0x9A, 0x4F, 0x2A, 0x40,
0x5D, 0x5F, 0x69, 0xF3, 0x15, 0xCB, 0x32, 0x96, 0xDC, 0xC0, 0x90, 0xA5, 0xF0, 0xBD, 0xAF, 0xC3, 0x96, 0x83, 0x51,
0xB8, 0xD6, 0xA7, 0x48, 0xD2, 0xE8, 0x2B, 0x2C, 0x2D, 0x6B, 0xF6, 0xA4, 0x55, 0x8E, 0x45, 0x5F, 0xBA, 0x54, 0x2A,
0x36, 0x77, 0x12, 0x72, 0xE5, 0x9C, 0x14, 0x3B, 0x63, 0x6C, 0x62, 0x8A, 0x2B, 0xDE, 0xCD, 0x70, 0x74, 0xA7, 0x92,
0xE2, 0x31, 0x35, 0x2D, 0x29, 0x54, 0xBB, 0x97, 0xBB, 0x18, 0xF3, 0x38, 0x52, 0xA5, 0x38, 0xDF, 0x92, 0x31, 0x7E,
0xEB, 0x9B, 0x6A, 0xCD, 0x6B, 0xEF, 0x7C, 0x6E, 0x52, 0x7F, 0xE1, 0xF7, 0x0E, 0x54, 0x96, 0x1B, 0x3D, 0x52, 0x86,
0xF0, 0x73, 0x4B, 0xEE, 0x92, 0xD6, 0xD6, 0xBF, 0xC2, 0x8D, 0x9F, 0x0B, 0xDE, 0xEB, 0xF3, 0x69, 0x6B, 0x06, 0x9B,
0xA9, 0x5B, 0xE9, 0xF6, 0x6F, 0x76, 0x1E, 0x45, 0x8E, 0xE1, 0x54, 0xC8, 0xCD, 0xF3, 0x28, 0x8B, 0x1C, 0x9E, 0x25,
0x55, 0xDA, 0xA5, 0x58, 0x80, 0xCA, 0x46, 0x42, 0xA8, 0xF4, 0x1B, 0xEB, 0xBB, 0xBB, 0x8B, 0x69, 0xAF, 0xA5, 0x8E,
0xD0, 0x49, 0x25, 0x9C, 0x37, 0x73, 0xE9, 0xD7, 0xF2, 0x14, 0x8E, 0x55, 0x91, 0x64, 0x90, 0x46, 0xA8, 0xF9, 0x50,
0x57, 0x73, 0x2E, 0xE0, 0x55, 0xF6, 0x87, 0x45, 0x20, 0x2E, 0xC7, 0x28, 0xAF, 0xCD, 0x78, 0xA6, 0xA5, 0x5C, 0x26,
0x33, 0x01, 0x35, 0x2E, 0x67, 0x52, 0x35, 0xA0, 0xEE, 0x93, 0x56, 0x70, 0x4F, 0x48, 0xDB, 0x95, 0x6B, 0x76, 0xF4,
0xB4, 0xA5, 0x27, 0x29, 0x29, 0x35, 0x16, 0xBF, 0xA8, 0x7C, 0x3E, 0x92, 0xF6, 0x98, 0xA8, 0x5B, 0x4B, 0xC5, 0xDB,
0x5E, 0x8E, 0x4F, 0x7D, 0xFA, 0xDB, 0x57, 0xB2, 0x56, 0xD6, 0xED, 0xF7, 0x3E, 0x10, 0xB2, 0x82, 0xF6, 0xC2, 0xF2,
0xF3, 0x4E, 0xD4, 0xAF, 0xEE, 0x5A, 0xCA, 0xCE, 0xD2, 0xF1, 0xA7, 0xB8, 0x9D, 0xD4, 0x42, 0x8C, 0xF2, 0x3C, 0x8E,
0x42, 0x02, 0x24, 0xFD, 0xDA, 0x96, 0x60, 0xAD, 0xB0, 0xC9, 0x0E, 0x00, 0xC8, 0xC8, 0xEA, 0xEE, 0x12, 0x7B, 0x09,
0x6C, 0x6E, 0xCE, 0xA2, 0x0B, 0xAA, 0x32, 0xD9, 0xB9, 0x49, 0x66, 0xDF, 0x74, 0xCC, 0x31, 0x1B, 0xE4, 0xE6, 0x63,
0xF2, 0x91, 0x95, 0x42, 0x15, 0x8B, 0x7D, 0xE0, 0xAC, 0xB4, 0x51, 0x5F, 0x33, 0x3A, 0xD8, 0x8C, 0x46, 0x7A, 0x9C,
0xA6, 0xD7, 0x2C, 0x13, 0x49, 0x25, 0x6E, 0x6A, 0x73, 0xE4, 0x84, 0x9E, 0x9A, 0xC9, 0x29, 0x24, 0x9E, 0xE9, 0x42,
0x11, 0x8D, 0xA2, 0x9C, 0x5F, 0xF5, 0x37, 0x0D, 0x36, 0xF8, 0x7E, 0x9A, 0xEC, 0xE5, 0xE5, 0x7E, 0x67, 0x77, 0x7B,
0x7A, 0xBF, 0x5E, 0xB7, 0x3F, 0xFF, 0xD9,
};
static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); }
class OnlineImageDownloadEnd : public ::testing::Test {
protected:
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format) {
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format, uint32_t buffer_size = 256) {
auto img = std::make_unique<OnlineImage>("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB,
image::TRANSPARENCY_OPAQUE, nullptr, 256);
image::TRANSPARENCY_OPAQUE, nullptr, buffer_size);
img->set_parent(&this->http_);
return img;
}
@@ -286,4 +458,294 @@ TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) {
EXPECT_EQ(this->finished_, 0);
}
// The JPEG decoded with all of it in memory at once, to compare the downloaded images with
static std::vector<uint8_t> reference_jpeg_pixels() {
runtime_image::RuntimeImage ref(runtime_image::JPEG, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
std::vector<uint8_t> data(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
EXPECT_TRUE(ref.begin_decode(data.size()));
EXPECT_EQ(ref.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
EXPECT_TRUE(ref.end_decode());
return std::vector<uint8_t>(ref.get_data_start(), ref.get_data_start() + ref.get_width_stride() * ref.get_height());
}
static std::vector<uint8_t> image_pixels(OnlineImage &image) {
return std::vector<uint8_t>(image.get_data_start(),
image.get_data_start() + image.get_width_stride() * image.get_height());
}
// A JPEG of known size is decoded as it arrives, so a buffer smaller than the file only has to hold the headers
TEST_F(OnlineImageDownloadEnd, JpegDecodesThroughASmallBuffer) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
// Bytes after the image are never needed. Waiting for the whole file before decoding would read them all.
std::vector<uint8_t> body(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
body.resize(body.size() + 8192, 0);
auto container = std::make_shared<FakeContainer>(body, body.size(), false);
container->add_response_header("etag", "\"abc\"");
this->start_(*image, container);
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
image->loop();
ASSERT_EQ(this->finished_, 1);
EXPECT_LT(container->get_bytes_read(), body.size()) << "the image was not decoded as it arrived";
EXPECT_EQ(this->errors_, 0);
ASSERT_EQ(image->get_width(), 48);
ASSERT_EQ(image->get_height(), 40);
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
// A complete image is cached
this->http_.next = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
image->update();
EXPECT_TRUE(this->http_.sent_header("if-none-match"));
}
// A read() waits for every byte it asks for, so a large buffer is filled a little at a time rather than in one
// read that blocks the loop until the whole buffer has arrived
TEST_F(OnlineImageDownloadEnd, JpegReadsInSmallPiecesWithALargeBuffer) {
auto image = this->make_image_(runtime_image::JPEG, 65536);
auto container =
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 0, false);
this->start_(*image, container);
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
container->deliver(sizeof(JPEG_48X40));
image->loop();
}
ASSERT_EQ(this->finished_, 1);
EXPECT_EQ(this->errors_, 0);
EXPECT_LE(container->largest_read, READ_CHUNK);
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
}
// A downloaded PNG is read a little at a time too
TEST_F(OnlineImageDownloadEnd, PngReadsInSmallPiecesWithALargeBuffer) {
auto image = this->make_image_(runtime_image::PNG, 65536);
std::vector<uint8_t> png(PNG_1X1.begin(), PNG_1X1.end());
auto container = std::make_shared<FakeContainer>(png, png.size(), false);
this->start_(*image, container);
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
image->loop();
ASSERT_EQ(this->finished_, 1);
EXPECT_EQ(this->errors_, 0);
EXPECT_LE(container->largest_read, READ_CHUNK);
}
// A slow link: the decoder waits part way through the image for the rest to arrive
TEST_F(OnlineImageDownloadEnd, JpegWaitsForASlowDownload) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
auto container =
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1024, false);
this->start_(*image, container);
int passes = 0;
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
image->loop();
if (passes == 5) {
EXPECT_EQ(this->finished_, 0) << "the image cannot be finished before the rest of it arrives";
}
container->deliver(40);
}
ASSERT_EQ(this->finished_, 1);
EXPECT_EQ(this->errors_, 0);
EXPECT_GT(passes, 10);
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
}
// A download that stops part way through times out rather than waiting for ever
TEST_F(OnlineImageDownloadEnd, JpegDownloadThatStopsTimesOut) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
auto container =
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1500, false);
set_loop_time(1000);
this->start_(*image, container);
for (int i = 0; i < 10; i++)
image->loop();
EXPECT_EQ(this->errors_, 0) << "still within the timeout";
set_loop_time(1000 + this->http_.get_timeout());
image->loop();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
// Headers that do not fit the download buffer cannot be read as the image arrives, so the whole image is
// downloaded first, keeping the bytes already read
TEST_F(OnlineImageDownloadEnd, JpegHeadersLargerThanTheBufferDownloadTheWholeImage) {
auto image = this->make_image_(runtime_image::JPEG, 256);
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
sizeof(JPEG_48X40), false);
this->start_(*image, container);
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
image->loop();
container->deliver(256);
}
ASSERT_EQ(this->finished_, 1);
EXPECT_EQ(this->errors_, 0);
EXPECT_EQ(container->get_bytes_read(), sizeof(JPEG_48X40));
EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels());
}
// The pass that opens the image does not decode any of it
TEST_F(OnlineImageDownloadEnd, JpegOpensInAPassOfItsOwn) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
sizeof(JPEG_48X40), false);
this->start_(*image, container);
image->loop();
EXPECT_EQ(this->finished_ + this->errors_, 0);
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
image->loop();
EXPECT_EQ(this->finished_, 1);
}
// A progressive JPEG is refused while it downloads, without blaming the buffer size
TEST_F(OnlineImageDownloadEnd, JpegProgressiveReportsError) {
auto image = this->make_image_(runtime_image::JPEG, 256);
std::vector<uint8_t> body(JPEG_PROGRESSIVE, JPEG_PROGRESSIVE + sizeof(JPEG_PROGRESSIVE));
auto container = std::make_shared<FakeContainer>(body, 256, false);
this->start_(*image, container);
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
image->loop();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
// on_download_started fires once the server has accepted the request, before the first progress report, and not
// for a response that has no image to download
TEST_F(OnlineImageDownloadEnd, StartedFiresOnlyWhenTheDownloadBegins) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
std::vector<std::string> events;
image->add_on_download_started_callback([&events]() { events.emplace_back("started"); });
image->add_on_progress_callback([&events](float, size_t) { events.emplace_back("progress"); });
this->start_(*image,
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)),
sizeof(JPEG_48X40), false));
ASSERT_GE(events.size(), 2u);
EXPECT_EQ(events[0], "started");
EXPECT_EQ(events[1], "progress");
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++)
image->loop();
EXPECT_EQ(std::count(events.begin(), events.end(), std::string("started")), 1);
for (int status : {304, 404}) {
events.clear();
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>{}, 0, false);
container->status_code = status;
this->http_.next = container;
image->update();
EXPECT_TRUE(events.empty()) << "HTTP " << status;
}
}
// Progress starts at 0, is reported at most every 100 ms, and always reaches 100%
TEST_F(OnlineImageDownloadEnd, ProgressIsReportedAsTheImageDownloads) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
std::vector<float> reports;
image->add_on_progress_callback([&reports](float progress, size_t) { reports.push_back(progress); });
auto container =
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 100, false);
uint32_t now = 1000;
set_loop_time(now);
this->start_(*image, container);
ASSERT_EQ(reports.size(), 1u);
EXPECT_EQ(reports[0], 0.0f);
int passes = 0;
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++) {
now += 50;
set_loop_time(now);
container->deliver(100);
image->loop();
}
ASSERT_EQ(this->finished_, 1);
EXPECT_EQ(reports.back(), 100.0f);
EXPECT_TRUE(std::is_sorted(reports.begin(), reports.end()));
// A report every 100 ms of a 50 ms loop, plus the start and the end
EXPECT_LE(reports.size(), static_cast<size_t>(passes / 2 + 2));
EXPECT_GT(reports.size(), 3u);
}
// Without a Content-Length the percentage is unknown, but the bytes read are still reported
TEST_F(OnlineImageDownloadEnd, ProgressOfAChunkedDownloadHasNoPercentage) {
auto image = this->make_image_(runtime_image::PNG);
std::vector<std::pair<float, size_t>> reports;
image->add_on_progress_callback(
[&reports](float progress, size_t bytes_read) { reports.emplace_back(progress, bytes_read); });
auto container =
std::make_shared<FakeContainer>(std::vector<uint8_t>(PNG_1X1.begin(), PNG_1X1.end()), PNG_1X1.size(), true);
set_loop_time(1000);
this->start_(*image, container);
set_loop_time(2000);
for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++)
image->loop();
ASSERT_GE(reports.size(), 2u);
EXPECT_TRUE(std::isnan(reports.back().first));
EXPECT_EQ(reports.back().second, PNG_1X1.size());
}
// Scan data that cannot be decoded fails the download part way through
TEST_F(OnlineImageDownloadEnd, JpegDecodeErrorFailsTheDownload) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
std::vector<uint8_t> body(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40));
std::fill(body.begin() + 700, body.end() - 2, 0xFE);
auto container = std::make_shared<FakeContainer>(body, 1024, false);
this->start_(*image, container);
for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) {
image->loop();
container->deliver(256);
}
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
// A read that fails part way through the download ends it with an error
TEST_F(OnlineImageDownloadEnd, JpegReadErrorFailsTheDownload) {
auto image = this->make_image_(runtime_image::JPEG, 1024);
auto container =
std::make_shared<FakeContainer>(std::vector<uint8_t>(JPEG_48X40, JPEG_48X40 + sizeof(JPEG_48X40)), 1024, false);
this->start_(*image, container);
for (int i = 0; i < 5; i++)
image->loop();
ASSERT_EQ(this->errors_, 0);
container->fail_reads();
image->loop();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
TEST(DownloadBufferGrow, KeepsTheUnreadData) {
DownloadBuffer buffer(16);
std::memcpy(buffer.append(), "abcdef", 6);
buffer.write(6);
buffer.read(2);
ASSERT_TRUE(buffer.grow(64));
EXPECT_EQ(buffer.size(), 64u);
ASSERT_EQ(buffer.unread(), 4u);
EXPECT_EQ(std::memcmp(buffer.data(), "cdef", 4), 0);
// A failed allocation leaves the buffer as it was
EXPECT_FALSE(buffer.grow(SIZE_MAX / 2));
EXPECT_EQ(buffer.size(), 64u);
EXPECT_EQ(std::memcmp(buffer.data(), "cdef", 4), 0);
// Growing to a smaller size changes nothing
EXPECT_TRUE(buffer.grow(8));
EXPECT_EQ(buffer.size(), 64u);
}
} // namespace esphome::online_image::testing
@@ -3,10 +3,13 @@
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <utility>
#include <vector>
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
@@ -362,6 +365,23 @@ static std::vector<uint8_t> pixel_bytes(TestableRuntimeImage &img) {
return std::vector<uint8_t>(start, start + img.get_width_stride() * img.get_height());
}
// Shrinking a PNG keeps the first source pixel of each span, as the JPEG path does: the even rows and columns
TEST(RuntimeImageDecoder, ShrunkPngKeepsTheFirstPixelOfEachSpan) {
TestableRuntimeImage img(PNG, image::IMAGE_TYPE_RGB, false, 2, 2);
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
ASSERT_EQ(img.get_width(), 2);
ASSERT_EQ(img.get_height(), 2);
for (int row = 0; row < 2; row++) {
for (int col = 0; col < 2; col++) {
const Color pixel = img.get_pixel(col, row);
const uint8_t *expected = PNG_RGB_EXPECTED[row * 2][col * 2];
EXPECT_EQ(pixel.r, expected[0]) << col << "," << row;
EXPECT_EQ(pixel.g, expected[1]) << col << "," << row;
EXPECT_EQ(pixel.b, expected[2]) << col << "," << row;
}
}
}
TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
TestableRuntimeImage img(JPEG);
@@ -377,6 +397,12 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels";
}
// The RGB565 word at (x, y) of a decoded image, in its own byte order
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
return p[0] | (p[1] << 8);
}
// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each
// channel is checked within one step; the byte order is still exact
static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) {
@@ -386,16 +412,12 @@ static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *o
}
TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
// Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those
// pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source
// pixel (2, 0), which is word 0x3803 here.
static const uint16_t EXPECTED[4 * 4] = {
0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F,
0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17,
};
// Halving the 8x8 gradient makes JPEGDEC decode it at half size, which averages each 2x2 block of pixels
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4);
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_EQ(rgb565_little_endian.get_width(), 4);
@@ -405,9 +427,45 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
const uint8_t *little = rgb565_little_endian.get_data_start();
const uint8_t *big = rgb565_big_endian.get_data_start();
for (size_t i = 0; i < 4 * 4; i++) {
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i);
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i);
for (int row = 0; row < 4; row++) {
for (int col = 0; col < 4; col++) {
int red = 0;
int green = 0;
int blue = 0;
for (int dy = 0; dy < 2; dy++) {
for (int dx = 0; dx < 2; dx++) {
const uint16_t word = rgb565_word(full, col * 2 + dx, row * 2 + dy);
red += word >> 11;
green += (word >> 5) & 0x3F;
blue += word & 0x1F;
}
}
const uint16_t expected = ((red + 2) / 4) << 11 | ((green + 2) / 4) << 5 | (blue + 2) / 4;
const size_t i = row * 4 + col;
expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), expected, "little-endian", i);
expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], expected, "big-endian", i);
}
}
}
// The generic draw() path gets JPEGDEC's half size RGB8888 output. It must match the half size RGB565 decode checked
// above. A full size RGB8888 decode is no reference: JPEGDEC's x86 SSE code writes it with red and blue swapped.
TEST(RuntimeImageDecoder, JpegDecodesToScaledRgb) {
TestableRuntimeImage rgb565(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4);
TestableRuntimeImage rgb(JPEG, image::IMAGE_TYPE_RGB, false, 4, 4);
ASSERT_TRUE(decode_all(rgb565, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_TRUE(decode_all(rgb, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_EQ(static_cast<JpegDecoder *>(rgb.decoder())->get_scale(), 2);
for (int row = 0; row < 4; row++) {
for (int col = 0; col < 4; col++) {
const uint16_t word = rgb565_word(rgb565, col, row);
const Color pixel = rgb.get_pixel(col, row);
// RGB565 keeps the top 5 or 6 bits of each channel
EXPECT_LE(std::abs(pixel.r - ((word >> 11) << 3)), 8) << "pixel " << col << "," << row << " red";
EXPECT_LE(std::abs(pixel.g - (((word >> 5) & 0x3F) << 2)), 4) << "pixel " << col << "," << row << " green";
EXPECT_LE(std::abs(pixel.b - ((word & 0x1F) << 3)), 8) << "pixel " << col << "," << row << " blue";
}
}
}
@@ -415,10 +473,43 @@ TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) {
// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start
static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; }
// The RGB565 word at (x, y) of a decoded image, in its own byte order
static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) {
const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2;
return p[0] | (p[1] << 8);
// Every target pixel of a PNG drawn at a size that is not a whole multiple: enlarged with spans 1 or 2 pixels wide, and
// shrunk with spans of 0, 1 or 2 pixels
TEST(RuntimeImageDecoder, ScaledPngPaintsEachSourcePixelOverItsSpan) {
// A fixed size keeps the aspect ratio, so both are square
for (auto [width, height] : {std::pair{6, 6}, std::pair{3, 3}}) {
TestableRuntimeImage img(PNG, image::IMAGE_TYPE_RGB, false, width, height);
ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB), PNG));
ASSERT_EQ(img.get_width(), width);
ASSERT_EQ(img.get_height(), height);
int painted = 0;
for (int m = 0; m < 4; m++) {
for (int n = 0; n < 4; n++) {
const uint8_t *expected = PNG_RGB_EXPECTED[m][n];
for (int y = scaled_boundary(m, 4, height); y < scaled_boundary(m + 1, 4, height); y++) {
for (int x = scaled_boundary(n, 4, width); x < scaled_boundary(n + 1, 4, width); x++) {
const Color pixel = img.get_pixel(x, y);
EXPECT_EQ(pixel.r, expected[0]) << width << "x" << height << " at " << x << "," << y;
EXPECT_EQ(pixel.g, expected[1]) << width << "x" << height << " at " << x << "," << y;
EXPECT_EQ(pixel.b, expected[2]) << width << "x" << height << " at " << x << "," << y;
painted++;
}
}
}
}
EXPECT_EQ(painted, width * height);
}
}
// JPEGDEC decodes at the smallest of full, 1/2, 1/4 and 1/8 size that still covers the buffer
TEST(RuntimeImageDecoder, JpegDecodesAtTheSmallestSizeThatCoversTheBuffer) {
for (auto [size, scale] :
{std::pair{8, 1}, std::pair{5, 1}, std::pair{4, 2}, std::pair{3, 2}, std::pair{2, 4}, std::pair{1, 8}}) {
TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
EXPECT_EQ(img.get_width(), size);
EXPECT_EQ(static_cast<JpegDecoder *>(img.decoder())->get_scale(), scale) << size << "x" << size;
}
}
// Every scaled direct decode must equal the unscaled one sampled through the boundary table,
@@ -427,7 +518,8 @@ TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) {
TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8);
ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
for (int size : {4, 6, 16}) {
// Sizes JPEGDEC cannot reach by scaling, so the full size decode is spread over the buffer
for (int size : {5, 6, 16}) {
TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size);
ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_EQ(scaled.get_width(), size);
@@ -5,6 +5,7 @@
#include <vector>
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#ifdef USE_RUNTIME_IMAGE_JPEG
@@ -178,6 +179,7 @@ class SourceImage : public RuntimeImage {
explicit SourceImage(image::ImageType type = image::IMAGE_TYPE_RGB, int width = 0, int height = 0)
: RuntimeImage(JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {}
size_t decoded_bytes() const { return this->decoded_bytes_; }
int scale() const { return static_cast<const JpegDecoder *>(this->decoder_.get())->get_scale(); }
};
/// The file in memory; fails reads once fail_at bytes have been read
@@ -291,6 +293,23 @@ TEST(JpegSourceDecode, SteppedDirectRgb565MatchesFedDecode) {
EXPECT_EQ(pixels(stepped), pixels(fed));
}
// decodeBegin() is given the same reduced scale as decode(), and decodeRows() keeps it for every step
TEST(JpegSourceDecode, SteppedScaledDecodeMatchesFedDecode) {
for (auto type : {image::IMAGE_TYPE_RGB565, image::IMAGE_TYPE_RGB}) {
// 24x20 is half of 48x40, so JPEGDEC decodes at 1/2 size
SourceImage fed(type, 24, 20);
ASSERT_TRUE(decode_fed(fed));
ASSERT_EQ(fed.scale(), 2);
SourceImage stepped(type, 24, 20);
MemorySource source(JPEG_ROWS, sizeof(JPEG_ROWS));
int steps;
ASSERT_EQ(decode_stepped(stepped, source, steps), DECODE_STEP_DONE);
ASSERT_EQ(stepped.scale(), 2);
EXPECT_EQ(pixels(stepped), pixels(fed)) << "image type " << type;
}
}
TEST(JpegSourceDecode, WaitsForDataThatHasNotArrived) {
SourceImage fed;
ASSERT_TRUE(decode_fed(fed));
@@ -30,7 +30,7 @@ uart_mock:
# Target state: 0x03 (moving + still)
# detection_distance = 100 (LD2412 computes from moving target when MOVE_BITMASK set)
#
# Frame layout (23 bytes):
# Frame layout (24 bytes):
# [0-3] F4 F3 F2 F1 = data frame header
# [4-5] 0D 00 = length 13
# [6] 02 = data type (normal)
@@ -41,9 +41,10 @@ uart_mock:
# [12-13] 78 00 = still distance 120 (0x0078)
# [14] 19 = still energy 25
# [15-16] 64 00 = detect distance bytes (ignored by LD2412 code)
# [17] 55 = data footer marker
# [18] 00 = CRC/check
# [19-22] F8 F7 F6 F5 = data frame footer
# [17] 00 = padding
# [18] 55 = data footer marker
# [19] 00 = CRC/check
# [20-23] F8 F7 F6 F5 = data frame footer
- delay: 100ms
inject_rx:
[
@@ -56,6 +57,7 @@ uart_mock:
0x78, 0x00,
0x19,
0x64, 0x00,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
@@ -67,13 +69,17 @@ uart_mock:
inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22]
# Phase 3 (t=400ms): Truncated frame (header + partial data, no footer)
# Starts with a valid data frame header, so the parser waits for the 23 bytes its length announces.
# Starts with valid data frame header so parser accepts it.
# After this, buffer_pos_ = 8.
- delay: 100ms
inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA]
# Phase 4 (t=600ms): 60 bytes of 0xFF
# The first 15 complete the truncated frame's 23 bytes without its footer, so it is dropped
# with an 'Invalid length' warning; the rest cannot start a header and are dropped too.
# Phase 4 (t=600ms): Overflow - inject 60 bytes of 0xFF (MAX_LINE_LENGTH=54)
# Buffer has 8 bytes from phase 3 (garbage in phase 2 was rejected).
# Overflow math: buffer_pos_ starts at 8, overflow triggers when
# buffer_pos_ reaches 53 (MAX_LINE_LENGTH - 1). Need 45 more bytes to
# fill positions 8-52, then byte 46 triggers overflow. After overflow,
# buffer_pos_ = 0 and remaining 0xFF bytes are rejected (don't match header).
- delay: 200ms
inject_rx:
[
@@ -85,8 +91,8 @@ uart_mock:
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
]
# Phase 5 (t=700ms): Valid frame after the garbage - recovery test
# The buffer is empty again, so this valid frame should parse correctly.
# Phase 5 (t=700ms): Valid frame after overflow - recovery test
# Buffer was reset by overflow. This valid frame should parse correctly.
# Moving target: 50cm, energy 100
# Still target: 75cm, energy 80
# detection_distance = 50 (moving target distance, since MOVE_BITMASK set)
@@ -102,72 +108,7 @@ uart_mock:
0x4B, 0x00,
0x50,
0x32, 0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 6 (t=3200ms): Values that spell the command footer 04 03 02 01
# Moving distance 772 (0x0304), moving energy 2, still distance 1: bytes 9 to 12 are the
# command frame footer, which must not end the frame early.
# Still energy 5, detection_distance = 772 (moving target distance, since MOVE_BITMASK set).
# The sensors hold and throttle values for a second, so the gap lets the phase 5 states
# reach the test before these replace them.
- delay: 2500ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x04, 0x03,
0x02,
0x01, 0x00,
0x05,
0x04, 0x03,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 7 (t=5700ms): Truncated frame merged with a complete one
# The eight header bytes announce a 23 byte frame, so its window takes in the first 15 bytes of
# the valid frame that follows. The merged frame has no footer where the count says, so it is
# dropped and the bytes after its header are replayed, which recovers the valid frame.
# Moving distance 400, moving energy 7, still distance 200, still energy 9, detection_distance = 400
- delay: 2500ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1, 0x0D, 0x00, 0x02, 0xAA,
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x90, 0x01,
0x07,
0xC8, 0x00,
0x09,
0x90, 0x01,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 8 (t=8200ms): Impossible counts, then a complete frame
# A command header counting 0xFFF6 bytes (65536 in total, which must not wrap to 0) and one counting
# 0xFFFF are each dropped as soon as the count arrives, and the valid frame after them is parsed.
# Moving distance 600, moving energy 11, still distance 300, still energy 13, detection distance 600
- delay: 2500ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0xF6, 0xFF,
0xFD, 0xFC, 0xFB, 0xFA, 0xFF, 0xFF,
0xF4, 0xF3, 0xF2, 0xF1,
0x0D, 0x00,
0x02, 0xAA,
0x03,
0x58, 0x02,
0x0B,
0x2C, 0x01,
0x0D,
0x58, 0x02,
0x00,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
@@ -1,88 +0,0 @@
esphome:
name: uart-mock-ld2412-short-answers
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
# Answers cut short, so the footer sits where their values would be, each followed by a whole one
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Version answer cut after two version bytes
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xA0, 0x01, 0x00, 0x00,
0x00, 0x01, 0x07, 0x02,
0x04, 0x03, 0x02, 0x01,
]
# Basic configuration answer cut after the minimum gate 7
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x05, 0x00, 0x12, 0x01, 0x00, 0x00,
0x07,
0x04, 0x03, 0x02, 0x01,
]
# Whole version answer: firmware type 1, version 2.07.22062416
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x0C, 0x00, 0xA0, 0x01, 0x00, 0x00,
0x00, 0x01, 0x07, 0x02, 0x16, 0x24, 0x06, 0x22,
0x04, 0x03, 0x02, 0x01,
]
# Whole basic configuration answer: gates 3 to 9, timeout 10 s, output pin level low
- delay: 100ms
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x09, 0x00, 0x12, 0x01, 0x00, 0x00,
0x03, 0x09, 0x0A, 0x00, 0x01,
0x04, 0x03, 0x02, 0x01,
]
ld2412:
id: ld2412_dev
uart_id: mock_uart
number:
- platform: ld2412
ld2412_id: ld2412_dev
min_distance_gate:
name: "Min Distance Gate"
max_distance_gate:
name: "Max Distance Gate"
timeout:
name: "Timeout"
text_sensor:
- platform: ld2412
ld2412_id: ld2412_dev
version:
name: "Version"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
@@ -37,7 +37,7 @@ uart_mock:
0x04, 0x00, # length 4
0xFE, 0x01, # answer to command 0x00FE
0x00, 0x00, # status: success
0x04, 0x03, 0x02, 0x01, # its footer; the 28 bytes the answer announced hold no footer, so it is dropped
0x04, 0x03, 0x02, 0x01, # its footer; the merged 40-byte frame gets dropped
]
# A complete answer after setup proves the parser recovered
injections:
+10 -105
View File
@@ -5,12 +5,9 @@ test_uart_mock_ld2412 (normal mode):
1. Happy path - valid data frame publishes correct sensor values
2. Garbage resilience - random bytes don't crash the component
3. Truncated frame handling - partial frame doesn't corrupt state
4. Truncated frame recovery - the frame is dropped once its length is reached
5. Post-garbage parsing - the next valid frame is parsed correctly
6. Footer bytes in the values - a frame whose values spell the command footer is parsed whole
7. Replay after a drop - a valid frame caught inside a dropped frame's window is still parsed
8. Impossible counts - a length field of 0xFFF6 or 0xFFFF is dropped and the next frame is parsed
9. TX logging - verifies LD2412 sends expected setup commands
4. Buffer overflow recovery - overflow resets the parser
5. Post-overflow parsing - next valid frame after overflow is parsed correctly
6. TX logging - verifies LD2412 sends expected setup commands
test_uart_mock_ld2412_engineering (engineering mode):
1. Engineering mode frames with per-gate energy data and light sensor
@@ -27,7 +24,6 @@ test_uart_mock_ld2412_engineering_truncated (truncated engineering mode):
The gate threshold tests (footer_in_payload, truncated_answer, requery_after_giveup) cover
answers that hold the frame footer or are truncated, writes that fill the gates without a
number from the module's values, and writes held back until the module reports them.
test_uart_mock_ld2412_short_answers: answers cut short publish nothing, the whole ones after them do.
"""
from __future__ import annotations
@@ -43,7 +39,6 @@ from aioesphomeapi import (
EntityState,
NumberInfo,
NumberState,
TextSensorState,
)
import pytest
@@ -144,7 +139,7 @@ async def test_uart_mock_ld2412(
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test LD2412 data parsing with happy path, garbage, a truncated frame, and recovery."""
"""Test LD2412 data parsing with happy path, garbage, overflow, and recovery."""
waiter = LineWaiter()
collector = SensorStateCollector(
@@ -170,22 +165,6 @@ async def test_uart_mock_ld2412(
and pytest.approx(100.0) in collector.sensor_states["moving_energy"]
and pytest.approx(80.0) in collector.sensor_states["still_energy"]
and pytest.approx(50.0) in collector.sensor_states["detection_distance"]
and pytest.approx(772.0) in collector.sensor_states["moving_distance"]
and pytest.approx(400.0) in collector.sensor_states["moving_distance"]
# Every value asserted below, so no state can still be in flight when the waiter fires
and pytest.approx(2.0) in collector.sensor_states["moving_energy"]
and pytest.approx(1.0) in collector.sensor_states["still_distance"]
and pytest.approx(5.0) in collector.sensor_states["still_energy"]
and pytest.approx(772.0) in collector.sensor_states["detection_distance"]
and pytest.approx(7.0) in collector.sensor_states["moving_energy"]
and pytest.approx(200.0) in collector.sensor_states["still_distance"]
and pytest.approx(9.0) in collector.sensor_states["still_energy"]
and pytest.approx(400.0) in collector.sensor_states["detection_distance"]
and pytest.approx(600.0) in collector.sensor_states["moving_distance"]
and pytest.approx(11.0) in collector.sensor_states["moving_energy"]
and pytest.approx(300.0) in collector.sensor_states["still_distance"]
and pytest.approx(13.0) in collector.sensor_states["still_energy"]
and pytest.approx(600.0) in collector.sensor_states["detection_distance"]
)
)
@@ -213,17 +192,17 @@ async def test_uart_mock_ld2412(
assert collector.sensor_states["detection_distance"][0] == pytest.approx(100.0)
# Wait for the recovery frame (Phase 5) to be parsed
# This proves the component survived garbage + truncated frame + filler bytes
# This proves the component survived garbage + truncated + overflow
await wait_or_fail(
recovery_received,
15.0,
3.0,
"Timeout waiting for recovery frame",
sensor_states=collector.sensor_states,
)
# Verify the truncated frame was dropped with a warning
assert any("Invalid length" in line for line in waiter.lines), (
"Expected 'Invalid length' warning in logs"
# Verify overflow warning was logged
assert any("Max command length exceeded" in line for line in waiter.lines), (
"Expected 'Max command length exceeded' warning in logs"
)
# Verify LD2412 sent setup commands (TX logging)
@@ -245,27 +224,6 @@ async def test_uart_mock_ld2412(
assert pytest.approx(80.0) in collector.sensor_states["still_energy"]
assert pytest.approx(50.0) in collector.sensor_states["detection_distance"]
# Phase 6: the values 04 03 02 01 did not end the frame early
assert pytest.approx(772.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(2.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(1.0) in collector.sensor_states["still_distance"]
assert pytest.approx(5.0) in collector.sensor_states["still_energy"]
assert pytest.approx(772.0) in collector.sensor_states["detection_distance"]
# Phase 7: the valid frame inside the dropped frame's window was replayed and parsed
assert pytest.approx(400.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(7.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(200.0) in collector.sensor_states["still_distance"]
assert pytest.approx(9.0) in collector.sensor_states["still_energy"]
assert pytest.approx(400.0) in collector.sensor_states["detection_distance"]
# Phase 8: both impossible counts were dropped and the frame after them was parsed
assert pytest.approx(600.0) in collector.sensor_states["moving_distance"]
assert pytest.approx(11.0) in collector.sensor_states["moving_energy"]
assert pytest.approx(300.0) in collector.sensor_states["still_distance"]
assert pytest.approx(13.0) in collector.sensor_states["still_energy"]
assert pytest.approx(600.0) in collector.sensor_states["detection_distance"]
# Verify binary sensors detected targets (from Phase 1 frame)
assert collector.binary_states["has_target"][0] is True
assert collector.binary_states["has_moving_target"][0] is True
@@ -573,7 +531,7 @@ async def test_uart_mock_ld2412_truncated_answer(
try:
_, gate_0_state, gate_5_state = await asyncio.gather(
waiter.wait_for("Invalid length", timeout=5.0),
waiter.wait_for("Dropping gate threshold answer", timeout=5.0),
gate_0_read,
gate_5_read,
)
@@ -695,56 +653,3 @@ async def test_uart_mock_ld2412_requery_after_giveup(
assert gate_0_state.state == pytest.approx(25.0)
assert gate_5_values
assert all(value == pytest.approx(30.0) for value in gate_5_values)
@pytest.mark.asyncio
async def test_uart_mock_ld2412_short_answers(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that answers cut short publish nothing and the whole answers after them do."""
loop = asyncio.get_running_loop()
# Number object_id: the value from the whole basic configuration answer
expected = {"min_distance_gate": 3.0, "max_distance_gate": 8.0, "timeout": 10.0}
number_states: dict[str, list[float]] = {name: [] for name in expected}
version_states: list[str] = []
key_to_name: dict[int, str] = {}
all_received = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
version_states.append(state.state)
elif (
isinstance(state, NumberState)
and not state.missing_state
and (name := key_to_name.get(state.key)) is not None
):
number_states[name].append(state.state)
if not all_received.done() and version_states and all(number_states.values()):
all_received.set_result(True)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
key_to_name.update(
{require_entity(entities, name, NumberInfo).key: name for name in expected}
)
await _wait_initial(client, entities, on_state)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
await wait_or_fail(
all_received,
5.0,
"Timeout waiting for the whole answers",
number_states=number_states,
version_states=version_states,
)
# Each entity saw exactly one value, the one from the whole answer
assert version_states == ["2.07.22062416"]
for name, value in expected.items():
assert number_states[name] == pytest.approx([value]), name