Compare commits

..
Author SHA1 Message Date
5e0e32552b [packet_interface] Add session and XXTEA encryption layers (#21088)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-24 22:09:59 +11:00
Clyde StubbsandClaude Opus 5.5 809bc71649 [packet_interface] Add layer registry and HDLC reliability layer (#21044)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:09:58 +11:00
Clyde StubbsandClaude Opus 5.5 7bec8aeb3d [udp] Add UDP packet interface link (#21042)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:09:58 +11:00
Clyde StubbsandClaude Opus 5.5 2897963905 [runtime_image] Draw BMP pixels opaque (#21424)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:52:04 +00:00
Christopher Pruijsen ace4651476 [as7341] Read channel data as little-endian (#21494) 2026-10-23 22:13:17 -10:00
Clyde StubbsandClaude Opus 5.5 b8d35764e7 [runtime_image] Write opaque RGB565 image directly (#21393)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:07:29 +11:00
Clyde StubbsandClaude Opus 5.5 afcf345264 [runtime_image] Limit how long one BMP or QOI decode call runs (#21392)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 07:48:01 +00:00
Clyde StubbsandClaude Opus 5.5 19ce03f4bf [runtime_image] Limit how long one PNG decode call runs (#21391)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 07:32:39 +00:00
Clyde StubbsandClaude Opus 5.5 83f460e964 [runtime_image] Work out grayscale pixels with integer maths (#21390)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:15:06 +11:00
Clyde StubbsandClaude Opus 5.5 e964a4d11a [runtime_image] Draw each target pixel once, with integer maths (#21387)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:00:14 +11:00
Peter Valkov 29a928ed5a [mqtt] mqtt_client does not honor name_add_mac_suffix for default log_topic, birth_message, will_message, shutdown_message (#9315) 2026-10-23 20:58:39 -10:00
Clyde Stubbs 2cbc1bc02d [http_request] Run requests without blocking the loop (#21486) 2026-10-24 06:14:45 +00:00
Petar Petrov 701e5746b0 [demo][lvgl][const] Move CONF_BUTTONS to the shared component constants (#21551) 2026-10-23 20:14:36 -10:00
77 changed files with 5792 additions and 473 deletions

No files matched your search

+1
View File
@@ -697,6 +697,7 @@ esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
esphome/components/udp/* @clydebarrow
esphome/components/udp/packet_interface/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
+2 -2
View File
@@ -110,12 +110,12 @@ bool AS7341Component::read_channels(uint16_t *data) {
this->set_smux_low_channels(true);
this->enable_spectral_measurement(true);
this->wait_for_data();
bool low_success = this->read_bytes_16(AS7341_CH0_DATA_L, data, 6);
bool low_success = this->read_bytes_16_le(AS7341_CH0_DATA_L, data, 6);
this->set_smux_low_channels(false);
this->enable_spectral_measurement(true);
this->wait_for_data();
bool high_sucess = this->read_bytes_16(AS7341_CH0_DATA_L, &data[6], 6);
bool high_sucess = this->read_bytes_16_le(AS7341_CH0_DATA_L, &data[6], 6);
return low_success && high_sucess;
}
+1
View File
@@ -19,6 +19,7 @@ CONF_BITRATE = "bitrate"
CONF_BLUE_CHANNEL = "blue_channel"
CONF_BORDER_ROUTER = "border_router"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BUTTONS = "buttons"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANNEL_COLORS = "channel_colors"
CONF_CLEAR_CHANNEL = "clear_channel"
+1 -1
View File
@@ -18,6 +18,7 @@ from esphome.components import (
text_sensor,
valve,
)
from esphome.components.const import CONF_BUTTONS
import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
@@ -167,7 +168,6 @@ VALVE_TYPES = {
CONF_ALARM_CONTROL_PANELS = "alarm_control_panels"
CONF_BUTTONS = "buttons"
CONF_CLIMATES = "climates"
CONF_COVERS = "covers"
CONF_DATETIMES = "datetimes"
+10 -3
View File
@@ -56,6 +56,7 @@ CONF_HTTP_REQUEST_ID = "http_request_id"
CONF_USERAGENT = "useragent"
CONF_FOLLOW_REDIRECTS = "follow_redirects"
CONF_REDIRECT_LIMIT = "redirect_limit"
CONF_MAX_CONCURRENT_REQUESTS = "max_concurrent_requests"
CONF_BUFFER_SIZE_RX = "buffer_size_rx"
CONF_BUFFER_SIZE_TX = "buffer_size_tx"
CONF_TLS_BUFFER_SIZE_RX = "tls_buffer_size_rx"
@@ -144,6 +145,10 @@ CONFIG_SCHEMA = cv.All(
): cv.string,
cv.Optional(CONF_FOLLOW_REDIRECTS, True): cv.boolean,
cv.Optional(CONF_REDIRECT_LIMIT, 3): cv.int_,
# Each request holds a client, and a TLS client about 40 kB
cv.Optional(CONF_MAX_CONCURRENT_REQUESTS, default=1): cv.int_range(
min=1, max=4
),
cv.Optional(
CONF_TIMEOUT, default="4.5s"
): cv.positive_time_period_milliseconds,
@@ -193,6 +198,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_useragent(config[CONF_USERAGENT]))
cg.add(var.set_follow_redirects(config[CONF_FOLLOW_REDIRECTS]))
cg.add(var.set_redirect_limit(config[CONF_REDIRECT_LIMIT]))
if (max_concurrent := config[CONF_MAX_CONCURRENT_REQUESTS]) != 1:
cg.add(var.set_max_concurrent_requests(max_concurrent))
if CORE.is_esp8266 and not config[CONF_ESP8266_DISABLE_SSL_SUPPORT]:
cg.add_define("USE_HTTP_REQUEST_ESP8266_HTTPS")
@@ -312,19 +319,19 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend(
"http_request.get",
HttpRequestSendAction,
HTTP_REQUEST_GET_ACTION_SCHEMA,
synchronous=True,
synchronous=False,
)
@automation.register_action(
"http_request.post",
HttpRequestSendAction,
HTTP_REQUEST_POST_ACTION_SCHEMA,
synchronous=True,
synchronous=False,
)
@automation.register_action(
"http_request.send",
HttpRequestSendAction,
HTTP_REQUEST_SEND_ACTION_SCHEMA,
synchronous=True,
synchronous=False,
)
async def http_request_action_to_code(
config: ConfigType,
@@ -1,5 +1,7 @@
#include "http_request.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -14,8 +16,10 @@ void HttpRequestComponent::dump_config() {
" Timeout: %" PRIu32 "ms\n"
" User-Agent: %s\n"
" Follow redirects: %s\n"
" Redirect limit: %d",
this->timeout_, this->useragent_, YESNO(this->follow_redirects_), this->redirect_limit_);
" Redirect limit: %d\n"
" Max concurrent requests: %u",
this->timeout_, this->useragent_, YESNO(this->follow_redirects_), this->redirect_limit_,
this->max_concurrent_);
if (this->watchdog_timeout_ > 0) {
ESP_LOGCONFIG(TAG, " Watchdog Timeout: %" PRIu32 "ms", this->watchdog_timeout_);
}
@@ -32,4 +36,209 @@ std::string HttpContainer::get_response_header(const std::string &header_name) {
return "";
}
void PendingRequest::free_buffer() {
if (this->buf != nullptr)
RAMAllocator<uint8_t>().deallocate(this->buf, this->buf_size);
this->buf = nullptr;
}
void HttpRequestComponent::setup() {
// An on_boot automation may already have queued a request
if (this->requests_ == nullptr)
this->disable_loop();
}
void HttpRequestComponent::loop() {
if (!this->process_())
this->disable_loop();
}
void HttpRequestComponent::on_safe_shutdown() {
// Called before any on_shutdown(). Drop the requests already made, so that they do not hold up the reboot;
// only the ones that on_shutdown automations make are sent.
this->shutting_down_ = true;
this->busy_++;
unsigned dropped = 0;
for (PendingRequest *request = this->requests_; request != nullptr; request = request->next) {
if (request->finished || request->cancelled)
continue;
dropped++;
request->cancelled = true;
// As script.stop would, so that a script that was waiting for it can run again from on_shutdown
request->handler(request, RequestEvent::REQUEST_EVENT_ABANDON);
}
this->busy_--;
if (dropped != 0) {
ESP_LOGW(TAG, "Shutting down; %u requests dropped", dropped);
}
if (this->busy_ == 0)
this->remove_dead_();
}
void HttpRequestComponent::on_shutdown() {
// Shutdown runs no loop(), so finish the requests here, blocking as they did before. A forced reboot calls
// only this, so it also finishes the requests made before it.
this->shutting_down_ = true;
this->drain_();
}
void HttpRequestComponent::submit(PendingRequest *request) {
this->busy_++;
uint8_t running = 0;
uint8_t waiting = 0;
PendingRequest **link = &this->requests_;
for (; *link != nullptr; link = &(*link)->next) {
const PendingRequest *other = *link;
if (other->finished || other->cancelled)
continue;
if (other->container == nullptr) {
waiting++;
} else {
running++;
}
}
*link = request;
this->enable_loop();
if (waiting >= MAX_QUEUED) {
ESP_LOGW(TAG, "Too many requests waiting; request dropped");
request->failed = true;
this->finish_(request);
} else if (waiting == 0 && running < this->max_concurrent_) {
// Start at once; one that cannot start is reported at once
this->begin_request_(request);
if (request->failed)
this->finish_(request);
}
this->busy_--;
if (this->busy_ != 0)
return;
if (this->shutting_down_) {
this->drain_();
} else {
this->remove_dead_();
}
}
void HttpRequestComponent::cancel(void *owner) {
for (PendingRequest *request = this->requests_; request != nullptr; request = request->next) {
if (request->owner == owner)
request->cancelled = true;
}
if (this->busy_ == 0)
this->remove_dead_();
}
bool HttpRequestComponent::process_() {
this->busy_++;
// The first max_concurrent_ live requests run; a request finished here frees its slot on the next pass
uint8_t slots = this->max_concurrent_;
for (PendingRequest *request = this->requests_; request != nullptr && slots != 0; request = request->next) {
if (request->finished || request->cancelled)
continue;
slots--;
if (request->container == nullptr)
this->begin_request_(request);
if (!request->cancelled && (request->failed || this->advance_(request)))
this->finish_(request);
}
this->busy_--;
if (this->busy_ == 0)
this->remove_dead_();
return this->requests_ != nullptr;
}
void HttpRequestComponent::drain_() {
// A call further up holds request pointers, and runs the new requests itself
if (this->busy_ != 0)
return;
while (this->process_()) {
App.feed_wdt();
delay(1);
}
}
void HttpRequestComponent::begin_request_(PendingRequest *request) {
request->start_time = millis();
request->handler(request, RequestEvent::REQUEST_EVENT_START);
// Stopped from the request's own lambdas: it is removed without being polled
if (!request->cancelled && request->container == nullptr)
request->failed = true;
}
bool HttpRequestComponent::advance_(PendingRequest *request) {
if (!request->reading) {
auto progress = request->container->poll();
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING)
return false;
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED) {
request->failed = true;
return true;
}
if (!request->capture)
return true;
request->buf = RAMAllocator<uint8_t>().allocate(request->buf_size);
if (request->buf == nullptr) {
// As before, on_response still fires, with an empty body
ESP_LOGW(TAG, "Could not allocate %zu bytes for the response body", request->buf_size);
return true;
}
request->reading = true;
// millis(), not the loop time: poll() may have blocked earlier in this pass
request->last_data_time = millis();
}
// NOTE: HttpContainer::read() has non-BSD socket semantics - see http_request.h
// millis(), not the loop time: the budget is shorter than one pass of the loop
const uint32_t start = millis();
while (request->read_index < request->buf_size) {
int read_or_error =
request->container->read_available(request->buf + request->read_index, request->buf_size - request->read_index);
const uint32_t now = millis();
// As before, a body that is cut off still fires on_response with what was read
if (read_or_error < 0) {
ESP_LOGW(TAG, "Response body read failed after %zu bytes", request->read_index);
return true;
}
if (read_or_error == 0) {
if (request->container->is_read_complete())
return true;
if (now - request->last_data_time < request->timeout)
return false;
ESP_LOGW(TAG, "Response body timed out after %zu bytes", request->read_index);
return true;
}
request->read_index += read_or_error;
request->last_data_time = now;
if (now - start >= READ_BUDGET_MS)
return false;
}
// As before, on_response still fires, with the part that fitted
if (!request->container->is_read_complete()) {
ESP_LOGW(TAG, "Response body is larger than max_response_buffer_size; kept the first %zu bytes", request->buf_size);
}
return true;
}
void HttpRequestComponent::finish_(PendingRequest *request) {
request->finished = true;
// The body was read over several passes, so time it from the start, as a blocking read would
if (request->reading && request->container != nullptr)
request->container->duration_ms = millis() - request->start_time;
request->handler(request, RequestEvent::REQUEST_EVENT_FINISH);
}
void HttpRequestComponent::remove_dead_() {
PendingRequest **link = &this->requests_;
while (PendingRequest *request = *link) {
if (!request->finished && !request->cancelled) {
link = &request->next;
continue;
}
*link = request->next;
if (request->container != nullptr)
request->container->end();
request->free_buffer();
request->handler(request, RequestEvent::REQUEST_EVENT_DESTROY);
}
}
} // namespace esphome::http_request
+162 -52
View File
@@ -12,6 +12,7 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -186,6 +187,13 @@ enum class HttpReadLoopResult : uint8_t {
TIMEOUT, ///< Timeout waiting for data, caller should exit loop
};
/// Progress of a request started with HttpRequestComponent::begin()
enum class HttpRequestProgress : uint8_t {
HTTP_REQUEST_PROGRESS_PENDING, ///< Still connecting, sending, or waiting for the response headers
HTTP_REQUEST_PROGRESS_DONE, ///< Response headers received; status_code is set and the body can be read
HTTP_REQUEST_PROGRESS_FAILED, ///< No response; the container must still be ended
};
/// Process a read result with timeout tracking and delay handling
/// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error
/// @param last_data_time Time of last successful read, updated when data received
@@ -249,8 +257,15 @@ class HttpContainer : public Parented<HttpRequestComponent> {
* - http_read_fully(): for simple "read N bytes" operations
*/
virtual int read(uint8_t *buf, size_t max_len) = 0;
/// Like read(), but returns 0 at once when no data has arrived yet, where read() may wait for it.
virtual int read_available(uint8_t *buf, size_t max_len) { return this->read(buf, max_len); }
virtual void end() = 0;
/// Advance a request started with HttpRequestComponent::begin(); call from the main loop until it is no
/// longer pending. Containers from start() are already done. Most calls return quickly, but on ESP-IDF the
/// first call for each host waits for the name lookup and the TCP connection, up to the whole timeout.
virtual HttpRequestProgress poll() { return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE; }
void set_secure(bool secure) { this->secure_ = secure; }
void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; }
uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; }
@@ -332,10 +347,56 @@ class HttpRequestResponseTrigger final : public Trigger<std::shared_ptr<HttpCont
}
};
/// What HttpRequestComponent asks of the action that owns a request
enum class RequestEvent : uint8_t {
REQUEST_EVENT_START, ///< Build the request and call begin()
REQUEST_EVENT_FINISH, ///< Fire the triggers and continue the action chain
REQUEST_EVENT_DESTROY, ///< Free the request
REQUEST_EVENT_ABANDON, ///< Dropped at shutdown: stop the action, so that it can run again
};
/// A request from an HttpRequestSendAction, run from HttpRequestComponent::loop().
/// The action derives from it to add the trigger arguments.
struct PendingRequest {
using Handler = void (*)(PendingRequest *request, RequestEvent event);
PendingRequest(Handler handler, void *owner) : handler(handler), owner(owner) {}
PendingRequest(const PendingRequest &) = delete;
PendingRequest &operator=(const PendingRequest &) = delete;
void free_buffer();
PendingRequest *next{nullptr};
Handler handler;
// The action, so that a stop of it can find its requests
void *owner;
// nullptr until the request has started
std::shared_ptr<HttpContainer> container;
uint8_t *buf{nullptr};
size_t buf_size{0};
size_t read_index{0};
uint32_t last_data_time{0};
uint32_t timeout{0};
uint32_t start_time{0};
bool capture{false};
bool reading{false};
bool failed{false};
bool finished{false};
bool cancelled{false};
};
class HttpRequestComponent : public Component {
public:
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void setup() override;
void loop() override;
void on_safe_shutdown() override;
void on_shutdown() override;
/// For HttpRequestSendAction: queue a request and take ownership of it. Requests start in order, up to
/// max_concurrent_requests at a time; one that can start at once is started here.
void submit(PendingRequest *request);
/// For HttpRequestSendAction: drop the requests of a stopped action without firing their triggers.
void cancel(void *owner);
void set_useragent(const char *useragent) { this->useragent_ = useragent; }
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
@@ -349,6 +410,7 @@ class HttpRequestComponent : public Component {
}
void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; }
void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; }
void set_max_concurrent_requests(uint8_t max_concurrent) { this->max_concurrent_ = max_concurrent; }
std::shared_ptr<HttpContainer> get(const char *url) { return this->start(url, "GET", "", std::vector<Header>{}); }
std::shared_ptr<HttpContainer> get(const char *url, const std::vector<Header> &request_headers) {
@@ -491,16 +553,44 @@ class HttpRequestComponent : public Component {
return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers);
}
/// Start a request without waiting for the response, then poll() the container until it is not pending.
/// Returns nullptr when the request cannot be started. Platforms without a non-blocking client complete
/// the request here.
virtual std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
return this->perform(url, method, body, request_headers, lower_case_collect_headers, timeout_ms);
}
protected:
virtual std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) = 0;
/// Longest time one loop() pass spends reading a response body
static constexpr uint32_t READ_BUDGET_MS = 10;
/// Requests that wait for a free slot; more are dropped with on_error
static constexpr uint8_t MAX_QUEUED = 4;
bool process_();
void drain_();
void begin_request_(PendingRequest *request);
bool advance_(PendingRequest *request);
void finish_(PendingRequest *request);
void remove_dead_();
const char *useragent_{nullptr};
bool follow_redirects_{};
uint16_t redirect_limit_{};
uint32_t timeout_{4500};
uint32_t watchdog_timeout_{0};
// Oldest first; the first max_concurrent_ live requests run
PendingRequest *requests_{nullptr};
// Depth of calls that hold request pointers; finished and cancelled requests are freed when it returns to 0
uint8_t busy_{0};
uint8_t max_concurrent_{1};
bool shutting_down_{false};
};
template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...> {
@@ -542,7 +632,42 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
this->max_response_buffer_size_ = max_response_buffer_size;
}
void play(const Ts &...x) override {
void play_complex(const Ts &...x) override {
this->num_running_++;
this->parent_->submit(new Request(this, x...)); // NOLINT(cppcoreguidelines-owning-memory)
}
void play(const Ts &...x) override { /* see play_complex */ }
protected:
struct Request final : PendingRequest {
Request(HttpRequestSendAction *action, const Ts &...x)
: PendingRequest(&HttpRequestSendAction::handle_event, action), args(x...) {}
std::tuple<stored_trigger_arg_t<Ts>...> args;
};
static void handle_event(PendingRequest *request, RequestEvent event) {
auto *self = static_cast<Request *>(request);
auto *action = static_cast<HttpRequestSendAction *>(request->owner);
switch (event) {
case RequestEvent::REQUEST_EVENT_START:
std::apply([action, self](auto &...x) { action->start_request_(*self, x...); }, self->args);
break;
case RequestEvent::REQUEST_EVENT_FINISH:
action->finish_(*self);
break;
case RequestEvent::REQUEST_EVENT_DESTROY:
delete self; // NOLINT(cppcoreguidelines-owning-memory)
break;
case RequestEvent::REQUEST_EVENT_ABANDON:
action->stop_complex();
break;
}
}
void stop() override { this->parent_->cancel(this); }
void start_request_(Request &request, const Ts &...x) {
std::string body;
if (this->body_.has_value()) {
body = this->body_.value(x...);
@@ -559,62 +684,47 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
request_headers.push_back({header.name, header.value(x...)});
}
const uint32_t timeout = this->timeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout();
auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body,
request_headers, this->lower_case_collect_headers_, timeout);
auto captured_args = std::make_tuple(x...);
if (container == nullptr) {
std::apply([this](Ts... captured_args_inner) { this->error_trigger_.trigger(captured_args_inner...); },
captured_args);
return;
}
request.timeout = this->timeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout();
#ifdef USE_HTTP_REQUEST_RESPONSE
if (this->capture_response_.value(x...)) {
size_t max_length = this->max_response_buffer_size_;
std::string response_body;
RAMAllocator<uint8_t> allocator;
uint8_t *buf = allocator.allocate(max_length);
if (buf != nullptr) {
// NOTE: HttpContainer::read() has non-BSD socket semantics - see top of this file
// Use http_read_loop_result() helper instead of checking return values directly
size_t read_index = 0;
uint32_t last_data_time = millis();
const uint32_t read_timeout = timeout;
while (container->get_bytes_read() < max_length) {
int read_or_error = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512));
App.feed_wdt();
yield();
auto result =
http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY)
continue;
if (result != HttpReadLoopResult::DATA)
break; // COMPLETE, ERROR, or TIMEOUT
read_index += read_or_error;
}
response_body.reserve(read_index);
response_body.assign((char *) buf, read_index);
allocator.deallocate(buf, max_length);
}
std::apply(
[this, &container, &response_body](Ts... captured_args_inner) {
this->success_trigger_with_response_.trigger(container, response_body, captured_args_inner...);
},
captured_args);
} else
request.capture = this->capture_response_.value(x...);
#endif
{
std::apply([this, &container](
Ts... captured_args_inner) { this->success_trigger_.trigger(container, captured_args_inner...); },
captured_args);
request.buf_size = this->max_response_buffer_size_;
request.container = this->parent_->begin(this->url_.value(x...).c_str(), this->method_.value(x...), std::move(body),
request_headers, this->lower_case_collect_headers_, request.timeout);
}
void finish_(Request &request) {
auto &container = request.container;
if (request.failed) {
std::apply([this](auto &...args) { this->error_trigger_.trigger(args...); }, request.args);
}
container->end();
#ifdef USE_HTTP_REQUEST_RESPONSE
else if (request.capture) {
std::string response_body;
if (request.buf != nullptr)
response_body.assign(reinterpret_cast<char *>(request.buf), request.read_index);
request.free_buffer();
std::apply(
[this, &container, &response_body](auto &...args) {
this->success_trigger_with_response_.trigger(container, response_body, args...);
},
request.args);
}
#endif
else {
std::apply([this, &container](auto &...args) { this->success_trigger_.trigger(container, args...); },
request.args);
}
// End it before the chain can start another request, so that one client is held at a time
if (container != nullptr) {
container->end();
container.reset();
}
// A stop from the triggers ended this run; a run started after it continues the chain instead
if (!request.cancelled)
std::apply([this](auto &...args) { this->play_next_(args...); }, request.args);
}
protected:
void encode_json_(Ts... x, JsonObject root) {
for (const auto &item : this->json_) {
auto val = item.second;
@@ -2,8 +2,13 @@
#ifdef USE_ESP32
#include <algorithm>
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/select.h>
#include "esphome/components/network/util.h"
#include "esphome/components/watchdog/watchdog.h"
@@ -20,6 +25,10 @@
namespace esphome::http_request {
ESPHOME_LOG_TAG(TAG, "http_request");
// Request body sent per esp_http_client_write() call, and the time one poll() spends sending it
static constexpr size_t SEND_CHUNK_SIZE = 1024;
static constexpr uint32_t SEND_BUDGET_MS = 10;
static constexpr uint32_t ERROR_DURATION_MS = 1000;
void HttpRequestIDF::dump_config() {
@@ -51,10 +60,9 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
return ESP_OK;
}
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
std::shared_ptr<HttpContainerIDF> HttpRequestIDF::create_container_(
const char *url, const char *method, const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers, uint32_t timeout_ms, [[maybe_unused]] bool async) {
if (!network::is_connected()) {
this->status_momentary_error(ERROR_DURATION_MS);
ESP_LOGE(TAG, "HTTP Request failed; Not connected to network");
@@ -106,11 +114,12 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
config.buffer_size = this->buffer_size_rx_;
config.buffer_size_tx = this->buffer_size_tx_;
const uint32_t start = millis();
const uint32_t watchdog_timeout = this->get_watchdog_timeout(timeout_ms);
watchdog::WatchdogManager wdm(watchdog_timeout);
config.event_handler = http_event_handler;
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
// Only TLS has a non-blocking connect worth having: the handshake. A plain connection waits for
// the TCP connection either way.
config.is_async = async && secure;
#endif
esp_http_client_handle_t client = esp_http_client_init(&config);
if (client == nullptr) {
@@ -121,7 +130,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
std::shared_ptr<HttpContainerIDF> container = std::make_shared<HttpContainerIDF>(client);
container->set_parent(this);
container->set_watchdog_timeout(watchdog_timeout);
container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms));
container->timeout_ms_ = timeout_ms;
container->async_ = config.is_async;
container->set_secure(secure);
@@ -132,101 +143,229 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
esp_http_client_set_header(client, header.name.c_str(), header.value.c_str());
}
const int body_len = body.length();
return container;
}
esp_err_t err = esp_http_client_open(client, body_len);
if (err != ESP_OK) {
this->status_momentary_error(ERROR_DURATION_MS);
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
esp_http_client_cleanup(client);
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
std::shared_ptr<HttpContainerIDF> container = this->start_container_(url, method, std::string(body), request_headers,
lower_case_collect_headers, timeout_ms, false);
if (container == nullptr) {
return nullptr;
}
// The same stages as begin(), run here until the response headers are in
watchdog::WatchdogManager wdm(container->get_watchdog_timeout());
HttpRequestProgress progress;
while ((progress = container->poll()) == HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING) {
container->feed_wdt();
delay(1);
}
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED) {
container->end();
return nullptr;
}
return container;
}
if (body_len > 0) {
int write_left = body_len;
int write_index = 0;
const char *buf = body.c_str();
while (write_left > 0) {
int written = esp_http_client_write(client, buf + write_index, write_left);
if (written <= 0) {
err = ESP_FAIL;
std::shared_ptr<HttpContainer> HttpRequestIDF::begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
return this->start_container_(url, method, std::move(body), request_headers, lower_case_collect_headers, timeout_ms,
true);
}
std::shared_ptr<HttpContainerIDF> HttpRequestIDF::start_container_(
const char *url, const char *method, std::string &&body, const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers, uint32_t timeout_ms, bool async) {
std::shared_ptr<HttpContainerIDF> container =
this->create_container_(url, method, request_headers, lower_case_collect_headers, timeout_ms, async);
if (container == nullptr) {
return nullptr;
}
container->body_ = std::move(body);
container->start_time_ = millis();
container->progress_time_ = container->start_time_;
container->redirects_left_ = this->follow_redirects_ ? this->redirect_limit_ : 0;
container->stage_ = HttpContainerIDF::Stage::STAGE_CONNECT;
return container;
}
HttpRequestIDF *HttpContainerIDF::idf_parent_() { return static_cast<HttpRequestIDF *>(this->parent_); }
HttpRequestProgress HttpContainerIDF::fail_(esp_err_t err) {
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
this->parent_->status_momentary_error(ERROR_DURATION_MS);
this->stage_ = Stage::STAGE_FAILED;
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
}
HttpRequestProgress HttpContainerIDF::wait_() {
// millis(), not the loop time: progress_time_ is set after calls that block within this pass
if (millis() - this->progress_time_ >= this->timeout_ms_) {
return this->fail_(ESP_ERR_TIMEOUT);
}
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
bool HttpContainerIDF::socket_ready_(bool write) {
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
const int fd = esp_http_client_get_socket(this->client_);
if (fd < 0) {
return true;
}
fd_set fds;
FD_ZERO(&fds);
FD_SET(fd, &fds);
timeval no_wait{};
// An error also counts as ready, so that the call that follows reports it
// Qualified: esphome::select is a namespace
return ::select(fd + 1, write ? nullptr : &fds, write ? &fds : nullptr, nullptr, &no_wait) != 0;
#else
return true;
#endif
}
// Each stage either finishes, or returns PENDING after a call that did not wait. The waits left are
// the name lookup and TCP connection in the first esp_http_client_open() of each hop: a non-blocking
// esp-tls connect that times out once never completes, as select() clears the sets it keeps.
HttpRequestProgress HttpContainerIDF::poll() {
while (true) {
switch (this->stage_) {
case Stage::STAGE_DONE:
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE;
case Stage::STAGE_FAILED:
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
case Stage::STAGE_CONNECT: {
// Only the first call of a hop waits
watchdog::WatchdogManager wdm(this->connecting_ ? 0 : this->get_watchdog_timeout());
esp_http_client_set_timeout_ms(this->client_, this->connecting_ ? 1 : this->timeout_ms_);
this->feed_wdt();
esp_err_t err = esp_http_client_open(this->client_, this->body_.size());
this->feed_wdt();
if (err == ESP_ERR_HTTP_EAGAIN) {
this->connecting_ = true;
return this->wait_();
}
this->connecting_ = false;
if (err != ESP_OK) {
return this->fail_(err);
}
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
if (this->async_) {
// Back to a blocking socket, so that mbedtls waits for the rest of a record instead of
// failing; reads only start once the socket is readable
const int fd = esp_http_client_get_socket(this->client_);
const int flags = fd < 0 ? -1 : fcntl(fd, F_GETFL, 0);
if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
ESP_LOGE(TAG, "Could not make the socket blocking: fd %d, errno %d", fd, errno);
return this->fail_(ESP_FAIL);
}
}
#endif
esp_http_client_set_timeout_ms(this->client_, this->timeout_ms_);
this->progress_time_ = millis();
this->stage_ = this->body_sent_ < this->body_.size() ? Stage::STAGE_SEND_BODY : Stage::STAGE_HEADERS;
break;
}
write_left -= written;
write_index += written;
container->feed_wdt();
}
}
if (err != ESP_OK) {
this->status_momentary_error(ERROR_DURATION_MS);
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
esp_http_client_cleanup(client);
return nullptr;
}
container->feed_wdt();
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header).
// The read() method handles content_length == 0 specially to support chunked responses.
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
container->status_code = esp_http_client_get_status_code(client);
container->feed_wdt();
container->duration_ms = millis() - start;
if (is_success(container->status_code)) {
return container;
}
if (this->follow_redirects_) {
auto num_redirects = this->redirect_limit_;
while (is_redirect(container->status_code) && num_redirects > 0) {
err = esp_http_client_set_redirection(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err));
this->status_momentary_error(ERROR_DURATION_MS);
esp_http_client_cleanup(client);
return nullptr;
case Stage::STAGE_SEND_BODY: {
// esp_http_client_write() returns only once all it was given is sent, so give it a piece at a time
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
const uint32_t start = millis();
while (this->body_sent_ < this->body_.size()) {
if (!this->socket_ready_(true)) {
return this->wait_();
}
const size_t len = std::min(this->body_.size() - this->body_sent_, SEND_CHUNK_SIZE);
int written = esp_http_client_write(this->client_, this->body_.data() + this->body_sent_, len);
this->feed_wdt();
if (written < 0) {
return this->fail_(ESP_FAIL);
}
if (written == 0) {
return this->wait_();
}
this->body_sent_ += written;
this->progress_time_ = millis();
if (this->progress_time_ - start >= SEND_BUDGET_MS && this->body_sent_ < this->body_.size()) {
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
}
this->body_ = std::string();
this->stage_ = Stage::STAGE_HEADERS;
break;
}
case Stage::STAGE_HEADERS: {
if (!this->socket_ready_(false)) {
if (millis() - this->progress_time_ < this->timeout_ms_) {
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
// As perform(), where esp_http_client_fetch_headers() times out: no response is status -1
return this->response_failed_(-1);
}
// The response has begun to arrive, so this waits only for the rest of the headers, or over TLS
// the rest of the record that holds them
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
this->feed_wdt();
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header)
const int64_t length = esp_http_client_fetch_headers(this->client_);
this->feed_wdt();
this->duration_ms = millis() - this->start_time_;
if (length < 0) {
return this->response_failed_(-1);
}
this->content_length = length;
this->set_chunked(esp_http_client_is_chunked_response(this->client_));
this->status_code = esp_http_client_get_status_code(this->client_);
if (is_success(this->status_code)) {
this->stage_ = Stage::STAGE_DONE;
break;
}
if (this->redirects_left_ > 0 && is_redirect(this->status_code)) {
esp_err_t err = esp_http_client_set_redirection(this->client_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err));
return this->fail_(err);
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char redirect_url[256]{};
if (esp_http_client_get_url(client, redirect_url, sizeof(redirect_url) - 1) == ESP_OK) {
ESP_LOGV(TAG, "redirecting to url: %s", redirect_url);
}
char redirect_url[256]{};
if (esp_http_client_get_url(this->client_, redirect_url, sizeof(redirect_url) - 1) == ESP_OK) {
ESP_LOGV(TAG, "redirecting to url: %s", redirect_url);
}
#endif
err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err));
this->status_momentary_error(ERROR_DURATION_MS);
esp_http_client_cleanup(client);
return nullptr;
this->redirects_left_--;
// Collect only the final response's headers; redirects do not resend the body
this->response_headers_.clear();
this->body_ = std::string();
this->body_sent_ = 0;
this->progress_time_ = millis();
this->stage_ = Stage::STAGE_CONNECT;
break;
}
if (is_redirect(this->status_code) && this->idf_parent_()->follow_redirects_) {
ESP_LOGW(TAG, "Reach redirect limit count=%d", this->idf_parent_()->redirect_limit_);
}
return this->response_failed_(this->status_code);
}
container->feed_wdt();
// IDF is the only backend reusing the container across redirect hops;
// drop the previous hop's headers (Arduino/host collect only the final response)
container->response_headers_.clear();
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
container->status_code = esp_http_client_get_status_code(client);
container->feed_wdt();
container->duration_ms = millis() - start;
if (is_success(container->status_code)) {
return container;
}
num_redirects--;
}
if (num_redirects == 0) {
ESP_LOGW(TAG, "Reach redirect limit count=%d", this->redirect_limit_);
}
}
}
ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code);
this->status_momentary_error(ERROR_DURATION_MS);
return container;
HttpRequestProgress HttpContainerIDF::response_failed_(int status_code) {
this->status_code = status_code;
this->duration_ms = millis() - this->start_time_;
char url[256]{};
esp_http_client_get_url(this->client_, url, sizeof(url) - 1);
ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, this->status_code);
this->parent_->status_momentary_error(ERROR_DURATION_MS);
this->stage_ = Stage::STAGE_DONE;
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE;
}
bool HttpContainerIDF::is_read_complete() const {
@@ -265,8 +404,21 @@ bool HttpContainerIDF::is_read_complete() const {
// after its internal transport timeout (configured via timeout_ms) expires.
// This is passed through as a negative return value, which callers treat as an error.
int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
esp_http_client_set_timeout_ms(this->client_, this->timeout_ms_);
return this->read_(buf, max_len);
}
int HttpContainerIDF::read_available(uint8_t *buf, size_t max_len) {
// A zero timeout only polls; TLS still waits for the rest of a record that has begun to arrive
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
esp_http_client_set_timeout_ms(this->client_, 0);
int read_len_or_error = this->read_(buf, max_len);
return read_len_or_error == -ESP_ERR_HTTP_EAGAIN ? 0 : read_len_or_error;
}
int HttpContainerIDF::read_(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
// Check if we've already read all expected content (non-chunked and no-body only).
// Use the base class check here, NOT the override: esp_http_client_is_complete_data_received()
@@ -5,30 +5,71 @@
#ifdef USE_ESP32
#include <esp_event.h>
#include <esp_idf_version.h>
#include <esp_http_client.h>
#include <esp_netif.h>
#include <esp_tls.h>
// The socket is needed to wait for the response without blocking; esp_http_client_get_socket() is new in 5.5.3
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 3)
#define USE_HTTP_REQUEST_IDF_SOCKET_POLL
#endif
namespace esphome::http_request {
class HttpRequestIDF;
class HttpContainerIDF : public HttpContainer {
public:
HttpContainerIDF(esp_http_client_handle_t client) : client_(client) {}
int read(uint8_t *buf, size_t max_len) override;
int read_available(uint8_t *buf, size_t max_len) override;
void end() override;
bool is_read_complete() const override;
HttpRequestProgress poll() override;
/// @brief Feeds the watchdog timer if the executing task has one attached
void feed_wdt();
protected:
friend class HttpRequestIDF;
enum class Stage : uint8_t {
STAGE_CONNECT,
STAGE_SEND_BODY,
STAGE_HEADERS,
STAGE_DONE,
STAGE_FAILED,
};
int read_(uint8_t *buf, size_t max_len);
/// True when the socket can be read or written without waiting; always true when it cannot be checked
bool socket_ready_(bool write);
HttpRequestProgress wait_();
HttpRequestProgress fail_(esp_err_t err);
/// Finish with an unsuccessful status, which is reported through on_response like a success
HttpRequestProgress response_failed_(int status_code);
HttpRequestIDF *idf_parent_();
esp_http_client_handle_t client_;
// Owned copy (not a reference): must outlive perform() for the response-header event handler
std::vector<std::string> collect_headers_;
// Request body for begin(), released once sent
std::string body_;
size_t body_sent_{0};
uint32_t timeout_ms_{0};
// Time of the last step forward, for the request timeout of begin()
uint32_t progress_time_{0};
uint32_t start_time_{0};
uint16_t redirects_left_{0};
Stage stage_{Stage::STAGE_DONE};
// An esp_http_client_open() of a non-blocking client returned ESP_ERR_HTTP_EAGAIN
bool connecting_{false};
bool async_{false};
};
class HttpRequestIDF final : public HttpRequestComponent {
friend class HttpContainerIDF;
public:
// User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero.
HttpRequestIDF() {}
@@ -40,7 +81,21 @@ class HttpRequestIDF final : public HttpRequestComponent {
void set_verify_ssl(bool verify_ssl) { this->verify_ssl_ = verify_ssl; }
void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; }
std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) override;
protected:
/// A container that poll() takes from the connect through to the response headers
std::shared_ptr<HttpContainerIDF> start_container_(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms, bool async);
std::shared_ptr<HttpContainerIDF> create_container_(const char *url, const char *method,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms, bool async);
std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
@@ -15,34 +15,25 @@ ESPHOME_LOG_TAG(TAG, "http_request.ota");
void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); };
OtaHttpRequestComponent::Request &OtaHttpRequestComponent::get_request_() {
if (this->request_ == nullptr)
this->request_ = make_unique<Request>();
return *this->request_;
}
void OtaHttpRequestComponent::set_md5_url(const std::string &url) {
if (!this->validate_url_(url)) {
if (this->request_ != nullptr)
this->request_->md5_url.clear(); // URL was not valid; prevent flashing until it is
this->md5_url_.clear(); // URL was not valid; prevent flashing until it is
return;
}
Request &request = this->get_request_();
request.md5_url = url;
request.md5_expected.clear(); // to be retrieved later
this->md5_url_ = url;
this->md5_expected_.clear(); // to be retrieved later
}
void OtaHttpRequestComponent::set_url(const std::string &url) {
if (!this->validate_url_(url)) {
if (this->request_ != nullptr)
this->request_->url.clear(); // URL was not valid; prevent flashing until it is
this->url_.clear(); // URL was not valid; prevent flashing until it is
return;
}
this->get_request_().url = url;
this->url_ = url;
}
void OtaHttpRequestComponent::flash() {
if (this->request_ == nullptr || this->request_->url.empty()) {
if (this->url_.empty()) {
ESP_LOGE(TAG, "URL not set; cannot start update");
return;
}
@@ -67,8 +58,8 @@ void OtaHttpRequestComponent::flash() {
#ifdef USE_OTA_STATE_LISTENER
this->notify_state_(ota::OTA_ERROR, 0.0f, ota_status);
#endif
this->request_->md5_computed.clear(); // will be reset at next attempt
this->request_->md5_expected.clear(); // will be reset at next attempt
this->md5_computed_.clear(); // will be reset at next attempt
this->md5_expected_.clear(); // will be reset at next attempt
break;
}
}
@@ -90,18 +81,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
md5::MD5Digest md5_receive;
char md5_receive_str[33];
if (this->request_->md5_expected.empty() && !this->http_get_md5_()) {
if (this->md5_expected_.empty() && !this->http_get_md5_()) {
return OTA_MD5_INVALID;
}
ESP_LOGD(TAG, "MD5 expected: %s", this->request_->md5_expected.c_str());
ESP_LOGD(TAG, "MD5 expected: %s", this->md5_expected_.c_str());
auto url_with_auth = this->get_url_with_auth_(this->request_->url);
auto url_with_auth = this->get_url_with_auth_(this->url_);
if (url_with_auth.empty()) {
return OTA_BAD_URL;
}
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->request_->url.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->url_.c_str());
auto container = this->parent_->get(url_with_auth);
@@ -188,9 +179,9 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
// verify MD5 is as expected and act accordingly
md5_receive.calculate();
md5_receive.get_hex(md5_receive_str);
this->request_->md5_computed = md5_receive_str;
if (strncmp(this->request_->md5_computed.c_str(), this->request_->md5_expected.c_str(), MD5_SIZE) != 0) {
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->request_->md5_computed.c_str());
this->md5_computed_ = md5_receive_str;
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
} else {
@@ -232,15 +223,11 @@ static std::string url_encode(const std::string &str) {
return result;
}
void OtaHttpRequestComponent::set_password(const std::string &password) {
this->get_request_().password = url_encode(password);
}
void OtaHttpRequestComponent::set_username(const std::string &username) {
this->get_request_().username = url_encode(username);
}
void OtaHttpRequestComponent::set_password(const std::string &password) { this->password_ = url_encode(password); }
void OtaHttpRequestComponent::set_username(const std::string &username) { this->username_ = url_encode(username); }
std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url) {
if (this->request_->username.empty() || this->request_->password.empty()) {
if (this->username_.empty() || this->password_.empty()) {
return url;
}
@@ -253,23 +240,23 @@ std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url)
ESP_LOGD(TAG, "Using basic HTTP authentication");
start_char += 3; // skip '://' characters
auto url_with_auth = url.substr(0, start_char) + this->request_->username + ":" + this->request_->password + "@" +
url.substr(start_char);
auto url_with_auth =
url.substr(0, start_char) + this->username_ + ":" + this->password_ + "@" + url.substr(start_char);
return url_with_auth;
}
bool OtaHttpRequestComponent::http_get_md5_() {
if (this->request_->md5_url.empty()) {
if (this->md5_url_.empty()) {
return false;
}
auto url_with_auth = this->get_url_with_auth_(this->request_->md5_url);
auto url_with_auth = this->get_url_with_auth_(this->md5_url_);
if (url_with_auth.empty()) {
return false;
}
ESP_LOGVV(TAG, "url_with_auth: %s", url_with_auth.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->request_->md5_url.c_str());
ESP_LOGI(TAG, "Connecting to: %s", this->md5_url_.c_str());
auto container = this->parent_->get(url_with_auth);
if (container == nullptr) {
ESP_LOGE(TAG, "Failed to connect to MD5 URL");
@@ -286,8 +273,8 @@ bool OtaHttpRequestComponent::http_get_md5_() {
return false;
}
this->request_->md5_expected.resize(MD5_SIZE);
auto result = http_read_fully(container.get(), (uint8_t *) this->request_->md5_expected.data(), MD5_SIZE, MD5_SIZE,
this->md5_expected_.resize(MD5_SIZE);
auto result = http_read_fully(container.get(), (uint8_t *) this->md5_expected_.data(), MD5_SIZE, MD5_SIZE,
this->parent_->get_timeout());
container->end();
@@ -27,36 +27,29 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void set_md5_url(const std::string &md5_url);
void set_md5(const std::string &md5) { this->get_request_().md5_expected = md5; }
void set_md5(const std::string &md5) { this->md5_expected_ = md5; }
void set_password(const std::string &password);
void set_url(const std::string &url);
void set_username(const std::string &username);
std::string md5_computed() { return this->request_ != nullptr ? this->request_->md5_computed : std::string(); }
std::string md5_expected() { return this->request_ != nullptr ? this->request_->md5_expected : std::string(); }
std::string md5_computed() { return this->md5_computed_; }
std::string md5_expected() { return this->md5_expected_; }
void flash();
protected:
/// Allocated by the first setter, which only runs before a flash, and never freed, so a flash never loses it.
struct Request {
std::string md5_computed;
std::string md5_expected;
std::string md5_url;
std::string password;
std::string username;
std::string url;
};
Request &get_request_();
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
// Only reached from flash(), which returns early while request_ is null
uint8_t do_ota_();
std::string get_url_with_auth_(const std::string &url);
bool http_get_md5_();
bool validate_url_(const std::string &url);
std::unique_ptr<Request> request_;
std::string md5_computed_{};
std::string md5_expected_{};
std::string md5_url_{};
std::string password_{};
std::string username_{};
std::string url_{};
int status_ = -1;
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
};
-1
View File
@@ -729,7 +729,6 @@ CONF_DISP_BG_COLOR = "disp_bg_color"
CONF_DISP_BG_IMAGE = "disp_bg_image"
CONF_DISP_BG_OPA = "disp_bg_opa"
CONF_BODY = "body"
CONF_BUTTONS = "buttons"
CONF_CHANGE_RATE = "change_rate"
CONF_CLICKABLE = "clickable"
CONF_CLOSE_BUTTON = "close_button"
@@ -1,6 +1,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_ROWS
from esphome.components.const import CONF_BUTTONS, CONF_ROWS
from esphome.components.key_provider import KeyProvider
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH
@@ -10,7 +10,6 @@ from esphome.types import Expression
from ..automation import action_to_code
from ..defines import (
BUTTONMATRIX_CTRLS,
CONF_BUTTONS,
CONF_CONTROL,
CONF_KEY_CODE,
CONF_MAIN,
+1 -1
View File
@@ -1,4 +1,5 @@
from esphome import codegen as cg, config_validation as cv
from esphome.components.const import CONF_BUTTONS
from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT
from esphome.core import ID
from esphome.cpp_generator import MockObjClass
@@ -7,7 +8,6 @@ from esphome.cpp_types import nullptr
from ..defines import (
CONF_BODY,
CONF_BUTTON_STYLE,
CONF_BUTTONS,
CONF_CLOSE_BUTTON,
CONF_HEADER_BUTTONS,
CONF_MAIN,
+65 -63
View File
@@ -87,6 +87,11 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObjClass
import esphome.final_validate as fv
from esphome.helpers import sanitize, snake_case
from esphome.mqtt_topics import (
DEFAULT_TOPIC_SUFFIXES,
per_board_suffix,
prefix_gets_mac_suffix,
)
from esphome.types import ConfigType
DEPENDENCIES = ["network"]
@@ -266,6 +271,31 @@ def _validate_transport(value: str) -> str:
return value
# (config key, extra fields when defaulted from the topic prefix, (setter, disabler))
_MESSAGES = (
(
CONF_BIRTH_MESSAGE,
{CONF_PAYLOAD: "online"},
lambda var: (var.set_birth_message, var.disable_birth_message),
),
(
CONF_WILL_MESSAGE,
{CONF_PAYLOAD: "offline"},
lambda var: (var.set_last_will, var.disable_last_will),
),
(
CONF_SHUTDOWN_MESSAGE,
{CONF_PAYLOAD: "offline"},
lambda var: (var.set_shutdown_message, var.disable_shutdown_message),
),
(
CONF_LOG_TOPIC,
{},
lambda var: (var.set_log_message_template, var.disable_log_message),
),
)
def validate_config(value):
if (
value.get(CONF_TRANSPORT) not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS)
@@ -280,45 +310,19 @@ def validate_config(value):
topic_prefix = value[CONF_TOPIC_PREFIX]
# If the topic prefix is not null and these messages are not configured, then set them to the default
# If the topic prefix is null and these messages are not configured, then set them to null
if CONF_BIRTH_MESSAGE not in value:
if topic_prefix != "":
out[CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: f"{topic_prefix}/status",
CONF_PAYLOAD: "online",
for key, extra, _ in _MESSAGES:
if key in value:
continue
out[key] = (
{}
if topic_prefix == ""
else {
CONF_TOPIC: f"{topic_prefix}{DEFAULT_TOPIC_SUFFIXES[key]}",
**extra,
CONF_QOS: 0,
CONF_RETAIN: True,
}
else:
out[CONF_BIRTH_MESSAGE] = {}
if CONF_WILL_MESSAGE not in value:
if topic_prefix != "":
out[CONF_WILL_MESSAGE] = {
CONF_TOPIC: f"{topic_prefix}/status",
CONF_PAYLOAD: "offline",
CONF_QOS: 0,
CONF_RETAIN: True,
}
else:
out[CONF_WILL_MESSAGE] = {}
if CONF_SHUTDOWN_MESSAGE not in value:
if topic_prefix != "":
out[CONF_SHUTDOWN_MESSAGE] = {
CONF_TOPIC: f"{topic_prefix}/status",
CONF_PAYLOAD: "offline",
CONF_QOS: 0,
CONF_RETAIN: True,
}
else:
out[CONF_SHUTDOWN_MESSAGE] = {}
if CONF_LOG_TOPIC not in value:
if topic_prefix != "":
out[CONF_LOG_TOPIC] = {
CONF_TOPIC: f"{topic_prefix}/debug",
CONF_QOS: 0,
CONF_RETAIN: True,
}
else:
out[CONF_LOG_TOPIC] = {}
)
return out
@@ -558,7 +562,7 @@ def _subscriptions(config: ConfigType) -> Iterator[tuple[tuple[str, ...], ...]]:
for device in esphome_config.get(CONF_DEVICES, [])
}
topic_prefix = config[CONF_TOPIC_PREFIX]
if mac_suffix and topic_prefix == CORE.name:
if prefix_gets_mac_suffix(topic_prefix, CORE.name, mac_suffix):
topic_prefix += MAC_SUFFIX
# Entities can sit at any depth (a platform can declare its own switches), so find them by mqtt_id
for id_, path in iter_ids(full_config):
@@ -607,12 +611,14 @@ def _validate_topic_lengths(config: ConfigType) -> ConfigType:
FINAL_VALIDATE_SCHEMA = _validate_topic_lengths
def exp_mqtt_message(config):
def exp_mqtt_message(
config: ConfigType | None, topic: cg.MockObj | None = None
) -> cg.Expression:
if config is None:
return cg.optional(cg.TemplateArguments(MQTTMessage))
return cg.StructInitializer(
MQTTMessage,
("topic", config[CONF_TOPIC]),
("topic", config[CONF_TOPIC] if topic is None else topic),
("payload", config.get(CONF_PAYLOAD, "")),
("qos", config[CONF_QOS]),
("retain", config[CONF_RETAIN]),
@@ -696,37 +702,33 @@ async def to_code(config):
)
)
cg.add(var.set_topic_prefix(config[CONF_TOPIC_PREFIX], CORE.name))
topic_prefix = config[CONF_TOPIC_PREFIX]
# The MAC suffix is only known at runtime, so the prefix and the topics defaulted from it
# are built from App.get_name(); topics written out by hand stay literal
mac_suffix = CORE.config[CONF_ESPHOME].get(CONF_NAME_ADD_MAC_SUFFIX, False)
mac_prefix = prefix_gets_mac_suffix(topic_prefix, CORE.name, mac_suffix)
cg.add(var.set_topic_prefix(cg.App.get_name() if mac_prefix else topic_prefix))
if config[CONF_USE_ABBREVIATIONS]:
cg.add_define("USE_MQTT_ABBREVIATIONS")
birth_message = config[CONF_BIRTH_MESSAGE]
if not birth_message:
cg.add(var.disable_birth_message())
else:
cg.add(var.set_birth_message(exp_mqtt_message(birth_message)))
will_message = config[CONF_WILL_MESSAGE]
if not will_message:
cg.add(var.disable_last_will())
else:
cg.add(var.set_last_will(exp_mqtt_message(will_message)))
shutdown_message = config[CONF_SHUTDOWN_MESSAGE]
if not shutdown_message:
cg.add(var.disable_shutdown_message())
else:
cg.add(var.set_shutdown_message(exp_mqtt_message(shutdown_message)))
for key, _, methods in _MESSAGES:
set_message, disable_message = methods(var)
if not (message := config[key]):
cg.add(disable_message())
continue
topic = (
cg.App.get_name() + suffix
if (suffix := per_board_suffix(config, key, CORE.name, mac_suffix))
else None
)
cg.add(set_message(exp_mqtt_message(message, topic)))
log_topic = config[CONF_LOG_TOPIC]
if not log_topic:
cg.add(var.disable_log_message())
else:
cg.add(var.set_log_message_template(exp_mqtt_message(log_topic)))
if log_topic := config[CONF_LOG_TOPIC]:
# Request a log listener slot only when log topic is enabled
logger.request_log_listener()
if CONF_LEVEL in log_topic:
cg.add(var.set_log_level(logger.LOG_LEVELS[log_topic[CONF_LEVEL]]))
if (level := log_topic.get(CONF_LEVEL)) is not None:
cg.add(var.set_log_level(logger.LOG_LEVELS[level]))
cg.add(var.set_keep_alive(config[CONF_KEEPALIVE]))
+1 -8
View File
@@ -737,14 +737,7 @@ void MQTTClientComponent::register_mqtt_component(MQTTComponent *component) { th
void MQTTClientComponent::set_keep_alive(uint16_t keep_alive_s) { this->mqtt_backend_.set_keep_alive(keep_alive_s); }
void MQTTClientComponent::set_log_message_template(MQTTMessage &&message) { this->log_message_ = std::move(message); }
const MQTTDiscoveryInfo &MQTTClientComponent::get_discovery_info() const { return this->discovery_info_; }
void MQTTClientComponent::set_topic_prefix(const std::string &topic_prefix, const std::string &check_topic_prefix) {
if (App.is_name_add_mac_suffix_enabled() && (topic_prefix == check_topic_prefix)) {
char buf[ESPHOME_DEVICE_NAME_MAX_LEN + 1];
this->topic_prefix_ = str_sanitize_to(buf, App.get_name().c_str());
} else {
this->topic_prefix_ = topic_prefix;
}
}
void MQTTClientComponent::set_topic_prefix(std::string topic_prefix) { this->topic_prefix_ = std::move(topic_prefix); }
const std::string &MQTTClientComponent::get_topic_prefix() const { return this->topic_prefix_; }
void MQTTClientComponent::disable_birth_message() {
this->birth_message_.topic = "";
+1 -1
View File
@@ -201,7 +201,7 @@ class MQTTClientComponent final : public Component {
*
* @param topic_prefix The topic prefix. The last "/" is appended automatically.
*/
void set_topic_prefix(const std::string &topic_prefix, const std::string &check_topic_prefix);
void set_topic_prefix(std::string topic_prefix);
/// Get the topic prefix of this device, using default if necessary
const std::string &get_topic_prefix() const;
@@ -231,30 +231,47 @@ void OnlineImage::loop() {
this->end_connection_();
this->download_error_callback_.call();
return;
} else if (this->downloader_->is_read_complete()) {
// The response has ended, so data left in the buffer can never be completed
size_t unread = this->download_buffer_.unread();
if (unread == 0 && this->downloader_->get_bytes_read() > 0) {
// A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes
if (this->is_decode_finished() || !this->decoder_knows_end()) {
this->finish_download_();
} else {
// Nothing new arrived. The decoder works for a limited time per call, so it may still have data to use.
if (this->download_buffer_.unread() != 0) {
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed));
this->end_connection_();
this->download_error_callback_.call();
return;
}
if (consumed > 0) {
this->download_buffer_.read(consumed);
this->last_data_time_ = App.get_loop_component_start_time();
return;
}
ESP_LOGE(TAG, "Download ended before the image was complete");
} else if (unread == 0) {
ESP_LOGE(TAG, "Server sent an empty response");
} else {
ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread);
}
this->end_connection_();
this->download_error_callback_.call();
return;
} else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) {
// ESP-IDF keeps returning 0 after the server closes the connection early
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
this->end_connection_();
this->download_error_callback_.call();
return;
if (this->downloader_->is_read_complete()) {
// The response has ended, so data left in the buffer can never be completed
size_t unread = this->download_buffer_.unread();
if (unread == 0 && this->downloader_->get_bytes_read() > 0) {
// A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes
if (this->is_decode_finished() || !this->decoder_knows_end()) {
this->finish_download_();
return;
}
ESP_LOGE(TAG, "Download ended before the image was complete");
} else if (unread == 0) {
ESP_LOGE(TAG, "Server sent an empty response");
} else {
ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread);
}
this->end_connection_();
this->download_error_callback_.call();
return;
} else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) {
// ESP-IDF keeps returning 0 after the server closes the connection early
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
this->end_connection_();
this->download_error_callback_.call();
return;
}
}
} else {
// Buffer is full, need to decode some data first
+51 -12
View File
@@ -1,3 +1,5 @@
import importlib
import pkgutil
from typing import Any
import esphome.codegen as cg
@@ -7,14 +9,27 @@ from esphome.const import CONF_ID
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
from . import layers
from .layers import (
PacketInterface,
build_stack,
check_session,
packet_interface_ns,
stack_auto_load,
stack_schema,
)
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "packet_interface"
IS_PLATFORM_COMPONENT = True
CONF_PACKET_INTERFACE = "packet_interface"
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
# Each module in layers registers a layer type; import them all before building the schemas from the registry
for module_info in pkgutil.iter_modules(layers.__path__):
importlib.import_module(f".layers.{module_info.name}", package=__package__)
LINK_LAYER_SCHEMAS = stack_schema()
__all__ = [
"CONF_MAX_PACKET_SIZE",
@@ -26,24 +41,47 @@ __all__ = [
]
def AUTO_LOAD(config: list[ConfigType] | None = None) -> list[str]:
"""Load only what the configured layers use.
Without a config, as tooling that lists dependencies calls it, everything a layer could load.
"""
if config is None:
return ["binary_sensor", "xxtea"]
loads: set[str] = set()
for interface in config:
loads |= stack_auto_load(interface)
return sorted(loads)
def _validate_layers(config: ConfigType) -> ConfigType:
check_session(config)
return config
def packet_interface_schema(
class_: MockObjClass,
default_max_packet_size: int,
max_packet_size_limit: int = 4096,
) -> cv.Schema:
"""Schema for a link platform."""
return cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
}
).extend(cv.COMPONENT_SCHEMA)
"""Schema for a link platform, with the layers that can be stacked on it."""
return (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(
CONF_MAX_PACKET_SIZE, default=default_max_packet_size
): cv.int_range(16, max_packet_size_limit),
**LINK_LAYER_SCHEMAS,
}
)
.extend(cv.COMPONENT_SCHEMA)
.add_extra(_validate_layers)
)
async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
"""Create the link layer.
"""Create the link layer and the layers configured on it, from the bottom up.
The link's constructor takes its maximum packet size, usually as a std::span over a buffer that code generation
sized from the configuration, so the two cannot differ.
@@ -53,4 +91,5 @@ async def new_packet_interface(config: ConfigType, *args: Any) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID], *args)
cg.add(var.set_name(config[CONF_ID].id))
await cg.register_component(var, config)
await build_stack(config, var)
return var
@@ -0,0 +1,135 @@
#include "basic_session.h"
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.session");
static constexpr uint8_t PACKET_TYPE_DATA = 0x00;
static constexpr uint8_t PACKET_TYPE_KEEP_ALIVE = 0x01;
// Type byte and the 32-bit boot id, little endian
static constexpr size_t KEEP_ALIVE_SIZE = 5;
BasicSession::BasicSession(PacketInterface *lower)
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), HEADER_SIZE)), lower_(lower) {
this->stack_on_(lower, CAPABILITY_SESSION);
// Data lost by a reliability layer below must be sent again, as after a restart of the peer
if (lower->has_capability(CAPABILITY_RELIABILITY)) {
lower->add_on_session_state_callback([this](bool connected) {
if (!connected && this->connected_)
this->set_connected_(false);
});
}
}
void BasicSession::setup() {
if (this->lower_->get_max_packet_size() < KEEP_ALIVE_SIZE) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
this->boot_id_ = random_uint32();
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_initial_state(false);
#endif
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
// A reliability layer below queues this until its link is up, so the peer hears of a restart straight away
this->send_keep_alive_();
this->set_interval(this->keep_alive_ms_, [this] { this->on_interval_(); });
}
void BasicSession::dump_config() {
ESP_LOGCONFIG(TAG,
"Basic Session on '%s':\n"
" Max Packet Size: %zu\n"
" Keep Alive: %" PRIu32 "ms\n"
" Timeout: %" PRIu32 "ms",
this->name_, this->max_packet_size_, this->keep_alive_ms_, this->timeout_ms_);
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_);
#endif
}
bool BasicSession::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->is_failed())
return false;
uint8_t header = PACKET_TYPE_DATA;
PacketBuffer packet(&header, HEADER_SIZE, &data, nullptr, 0);
return this->lower_->send_to_interface(packet, meta_data);
}
void BasicSession::send_keep_alive_() {
uint8_t packet[KEEP_ALIVE_SIZE] = {
PACKET_TYPE_KEEP_ALIVE, static_cast<uint8_t>(this->boot_id_), static_cast<uint8_t>(this->boot_id_ >> 8),
static_cast<uint8_t>(this->boot_id_ >> 16), static_cast<uint8_t>(this->boot_id_ >> 24)};
if (!this->lower_->send_to_interface(PacketBuffer(packet, sizeof(packet)), {}))
ESP_LOGV(TAG, "%s: Lower layer did not accept keep-alive", this->name_);
}
void BasicSession::on_interval_() {
this->send_keep_alive_();
if (this->connected_ && App.get_loop_component_start_time() - this->last_receive_ms_ > this->timeout_ms_) {
ESP_LOGW(TAG, "%s: Peer not heard for %" PRIu32 "ms", this->name_, this->timeout_ms_);
this->set_connected_(false);
}
}
void BasicSession::set_connected_(bool connected) {
this->connected_ = connected;
ESP_LOGD(TAG, "%s: Session %s", this->name_, connected ? LOG_STR_LITERAL("connected") : LOG_STR_LITERAL("lost"));
#ifdef USE_BINARY_SENSOR
if (this->connected_sensor_ != nullptr)
this->connected_sensor_->publish_state(connected);
#endif
this->session_callback_.call(connected);
}
void BasicSession::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto packet = data.as_span();
if (packet.empty()) {
this->note_received_(false);
return;
}
uint8_t type = packet[0];
if (type == PACKET_TYPE_KEEP_ALIVE) {
if (packet.size() != KEEP_ALIVE_SIZE) {
ESP_LOGV(TAG, "%s: Keep-alive of %zu bytes dropped", this->name_, packet.size());
this->note_received_(false);
return;
}
uint32_t boot_id = encode_uint32(packet[4], packet[3], packet[2], packet[1]);
if (!this->peer_known_ || boot_id != this->peer_boot_id_) {
// Data may arrive before the first keep-alive, so only a changed id means a restart
if (this->connected_ && this->peer_known_) {
ESP_LOGD(TAG, "%s: Peer has restarted", this->name_);
this->set_connected_(false);
}
this->peer_boot_id_ = boot_id;
this->peer_known_ = true;
// Answer a new peer at once; it answers only if our id is new to it, so this cannot repeat
this->send_keep_alive_();
}
} else if (type != PACKET_TYPE_DATA) {
ESP_LOGV(TAG, "%s: Unknown packet type 0x%02X dropped", this->name_, type);
this->note_received_(false);
return;
}
this->note_received_(true);
this->last_receive_ms_ = App.get_loop_component_start_time();
if (!this->connected_)
this->set_connected_(true);
if (type == PACKET_TYPE_DATA)
this->on_receive_from_interface_(PacketBuffer(packet.data() + HEADER_SIZE, packet.size() - HEADER_SIZE), meta_data);
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,61 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_BASIC_SESSION
#include "packet_interface.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::packet_interface {
/**
* A session layer that reports whether the peer can be reached.
*
* Every packet starts with a type byte. A data packet carries the payload of the layer above. A keep-alive packet
* carries a random number chosen at boot, so that a restart of the peer is noticed even if it is quicker than the
* timeout. A keep-alive is sent every keep_alive interval, and at once when a new peer is heard. The peer is
* considered gone when nothing has been received from it for the timeout; this is checked every keep_alive interval.
* A restart of the peer is reported as a disconnect followed by a connect. So is a reset of a reliability layer below,
* which discards data: the session is reported lost, and connected again when the peer is next heard.
*/
class BasicSession : public PacketInterface {
public:
/// The packet type byte.
static constexpr size_t HEADER_SIZE = 1;
explicit BasicSession(PacketInterface *lower);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
void set_keep_alive(uint32_t keep_alive_ms) { this->keep_alive_ms_ = keep_alive_ms; }
void set_session_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
#ifdef USE_BINARY_SENSOR
void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; }
#endif
bool is_connected() const { return this->connected_; }
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
void on_interval_();
void send_keep_alive_();
void set_connected_(bool connected);
PacketInterface *lower_;
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *connected_sensor_{nullptr};
#endif
uint32_t keep_alive_ms_{10000};
uint32_t timeout_ms_{30000};
uint32_t last_receive_ms_{0};
uint32_t boot_id_{0};
uint32_t peer_boot_id_{0};
bool peer_known_{false};
bool connected_{false};
};
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,365 @@
#include "hdlc_reliability.h"
#ifdef USE_PACKET_INTERFACE_HDLC
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.hdlc");
static constexpr uint32_t RETRANSMIT_TIMER_ID = 0;
static constexpr uint32_t ACK_TIMER_ID = 1;
static constexpr uint32_t SEND_RETRY_TIMER_ID = 2;
// A frame or acknowledgement the lower layer refused, for example while a radio was receiving, is sent again after this
static constexpr uint32_t SEND_RETRY_MS = 50;
// SABM is repeated at this interval, or the timeout if longer, until the peer answers
static constexpr uint32_t RESET_INTERVAL_MS = 1000;
// Control byte formats, with the poll/final bit (0x10) ignored
static constexpr uint8_t POLL_FINAL_BIT = 0x10;
static constexpr uint8_t SUPERVISORY_FRAME = 0x01;
static constexpr uint8_t SUPERVISORY_RR = 0x00;
static constexpr uint8_t SUPERVISORY_REJ = 0x02;
static constexpr uint8_t UNNUMBERED_SABM = 0x2F;
static constexpr uint8_t UNNUMBERED_UA = 0x63;
static uint16_t frame_crc(uint8_t control, const uint8_t *payload, size_t length) {
uint16_t crc = crc16(&control, 1);
return crc16(payload, length, crc);
}
HdlcReliability::HdlcReliability(PacketInterface *lower)
: PacketInterface(remaining_packet_size(lower->get_max_packet_size(), FRAME_OVERHEAD)), lower_(lower) {
this->stack_on_(lower, CAPABILITY_RELIABILITY);
}
void HdlcReliability::setup() {
if (this->max_packet_size_ == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
RAMAllocator<uint8_t> allocator;
this->tx_slots_ = allocator.allocate(size_t{this->max_queue_length_} * this->max_packet_size_);
if (this->tx_slots_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the send queue"));
return;
}
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void HdlcReliability::dump_config() {
ESP_LOGCONFIG(TAG,
"HDLC Reliability on '%s':\n"
" Max Packet Size: %zu\n"
" Timeout: %" PRIu32 "ms\n"
" Ack Delay: %" PRIu32 "ms\n"
" Max Queue Length: %u\n"
" Max Retries: %u",
this->name_, this->max_packet_size_, this->timeout_ms_, this->ack_delay_ms_, this->max_queue_length_,
this->max_retries_);
}
void HdlcReliability::loop() {
// Disabled first, as sending may deliver a reply to this layer and request the loop again
this->disable_loop();
if (this->state_ == LinkState::LINK_STATE_RESET_PENDING) {
this->state_ = LinkState::LINK_STATE_RESETTING;
this->start_timer_();
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
}
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
return;
// Queued frames go first, as each carries the acknowledgement
if (this->send_pending_() && this->ack_due_ && !this->send_supervisory_(SUPERVISORY_RR))
this->retry_send_();
}
void HdlcReliability::retry_send_() {
this->set_timeout(SEND_RETRY_TIMER_ID, SEND_RETRY_MS, [this]() { this->request_loop_(); });
}
void HdlcReliability::owe_ack_(bool now) {
if (now || this->ack_delay_ms_ == 0) {
this->ack_due_ = true;
this->request_loop_();
} else if (!this->ack_pending_) {
// Waiting lets one acknowledgement cover several frames, or a reply carry it
this->set_timeout(ACK_TIMER_ID, this->ack_delay_ms_, [this]() {
this->ack_due_ = true;
this->request_loop_();
});
}
this->ack_pending_ = true;
}
void HdlcReliability::ack_sent_() {
this->ack_pending_ = false;
this->ack_due_ = false;
this->cancel_timeout(ACK_TIMER_ID);
}
void HdlcReliability::request_loop_() { this->enable_loop(); }
bool HdlcReliability::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_slots_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
uint8_t outstanding = this->outstanding_();
if (outstanding >= this->max_queue_length_) {
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
ESP_LOGV(TAG, "%s: Send queue full", this->name_);
} else if (!this->reset_wait_logged_) {
// Once for each reset; the layers above keep trying while the peer is away
this->reset_wait_logged_ = true;
ESP_LOGD(TAG, "%s: Send queue full, waiting for the peer to answer the link reset", this->name_);
}
return false;
}
uint8_t seq = this->vs_;
data.copy_to(this->slot_(seq), size);
this->tx_lengths_[this->slot_index_(seq)] = size;
this->vs_ = (seq + 1) & 7;
// While the link is being reset, the frame waits in the queue and is sent when UA arrives
if (this->state_ == LinkState::LINK_STATE_CONNECTED) {
this->send_pending_();
if (outstanding == 0)
this->start_timer_();
}
return true;
}
bool HdlcReliability::send_frame_(uint8_t control, const uint8_t *payload, size_t length) {
uint16_t crc = frame_crc(control, payload, length);
uint8_t trailer[2] = {static_cast<uint8_t>(crc), static_cast<uint8_t>(crc >> 8)};
PacketBuffer body(payload, length);
PacketBuffer frame(&control, 1, length != 0 ? &body : nullptr, trailer, sizeof(trailer));
if (this->lower_->send_to_interface(frame, {}))
return true;
ESP_LOGV(TAG, "%s: Lower layer did not accept frame 0x%02X", this->name_, control);
return false;
}
bool HdlcReliability::send_supervisory_(uint8_t type) {
if (!this->send_frame_(SUPERVISORY_FRAME | (type << 2) | (this->vr_ << 5), nullptr, 0))
return false;
this->ack_sent_();
return true;
}
// Sends the next queued frame not yet sent, and returns true if none are left. One frame per loop pass lets other
// components run between frames: on a radio, sending blocks, and the peer's frames are received in between. If the
// lower layer refuses the frame, it is sent again shortly.
bool HdlcReliability::send_pending_() {
if (this->vn_ == this->vs_)
return true;
uint8_t control = (this->vn_ << 1) | (this->vr_ << 5);
if (!this->send_frame_(control, this->slot_(this->vn_), this->tx_lengths_[this->slot_index_(this->vn_)])) {
this->retry_send_();
return false;
}
// Every I-frame carries an acknowledgement, so a pending RR is no longer needed
this->ack_sent_();
this->vn_ = (this->vn_ + 1) & 7;
if (this->vn_ == this->vs_)
return true;
this->request_loop_();
return false;
}
void HdlcReliability::resend_outstanding_() {
this->vn_ = this->va_;
this->send_pending_();
}
void HdlcReliability::start_timer_() {
uint32_t interval = this->timeout_ms_;
if (this->state_ != LinkState::LINK_STATE_CONNECTED && interval < RESET_INTERVAL_MS)
interval = RESET_INTERVAL_MS;
// Up to a quarter longer at random, so devices whose frames collided on a shared channel do not resend together
interval += random_uint32() % (interval / 4 + 1);
this->set_timeout(RETRANSMIT_TIMER_ID, interval, [this]() { this->on_timeout_(); });
}
void HdlcReliability::on_timeout_() {
if (this->state_ != LinkState::LINK_STATE_CONNECTED) {
// Counted up to max_retries_ only, so the warning is logged once until the link comes up
if (this->retries_ < this->max_retries_) {
this->retries_++;
if (this->retries_ == this->max_retries_) {
ESP_LOGW(TAG, "%s: No answer to the link reset after %u tries; check that the peer has a reliability layer",
this->name_, this->max_retries_);
}
}
this->send_frame_(UNNUMBERED_SABM, nullptr, 0);
this->start_timer_();
return;
}
if (this->outstanding_() == 0)
return;
if (this->retries_ == this->max_retries_) {
ESP_LOGW(TAG, "%s: No acknowledgement after %u retries, resetting link", this->name_, this->max_retries_);
this->start_reset_();
return;
}
this->retries_++;
ESP_LOGV(TAG, "%s: Timeout, resending %u frames", this->name_, this->outstanding_());
this->resend_outstanding_();
this->start_timer_();
}
// Unacknowledged frames are discarded; frames queued from now on are numbered from 0 and sent once the link is up.
// Resetting a connected link can lose data both ways, which is reported to the layers above.
void HdlcReliability::start_reset_() {
bool was_connected = this->state_ == LinkState::LINK_STATE_CONNECTED;
this->state_ = LinkState::LINK_STATE_RESET_PENDING;
this->va_ = this->vs_ = this->vn_ = 0;
this->head_ = 0;
this->vr_ = 0;
this->retries_ = 0;
this->rej_sent_ = false;
this->ack_sent_();
this->cancel_timeout(RETRANSMIT_TIMER_ID);
this->request_loop_();
if (was_connected)
this->session_callback_.call(false);
}
void HdlcReliability::link_up_() {
ESP_LOGD(TAG, "%s: Link connected", this->name_);
this->state_ = LinkState::LINK_STATE_CONNECTED;
this->reset_wait_logged_ = false;
this->peer_frame_seen_ = false;
this->retries_ = 0;
this->cancel_timeout(RETRANSMIT_TIMER_ID);
if (this->outstanding_() != 0) {
this->resend_outstanding_();
this->start_timer_();
}
}
void HdlcReliability::process_ack_(uint8_t nr) {
uint8_t acked = (nr - this->va_) & 7;
if (acked > this->outstanding_()) {
ESP_LOGV(TAG, "%s: Ignoring invalid N(R) %u", this->name_, nr);
return;
}
if (acked == 0)
return;
// The peer may have had frames that were refused when last resent
if (acked > ((this->vn_ - this->va_) & 7))
this->vn_ = nr;
this->head_ = (this->head_ + acked) % this->max_queue_length_;
this->va_ = nr;
this->retries_ = 0;
if (this->outstanding_() == 0) {
this->cancel_timeout(RETRANSMIT_TIMER_ID);
} else {
this->start_timer_();
}
}
void HdlcReliability::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
// Lower layers deliver packets in one piece; the payload is passed up without copying
if (!data.is_contiguous()) {
ESP_LOGW(TAG, "%s: Packet split across buffers is not supported", this->name_);
return;
}
auto frame = data.as_span();
if (frame.size() < FRAME_OVERHEAD) {
this->note_received_(false);
return;
}
size_t length = frame.size() - FRAME_OVERHEAD;
uint8_t control = frame[0];
const uint8_t *payload = frame.data() + 1;
uint16_t crc = frame[length + 1] | (frame[length + 2] << 8);
if (frame_crc(control, payload, length) != crc) {
ESP_LOGV(TAG, "%s: CRC error, frame dropped", this->name_);
this->note_received_(false);
return;
}
// The peer's packets may only be larger if it has a larger max_packet_size, which the layers above cannot take
if (length > this->max_packet_size_) {
if (!this->oversize_warned_) {
this->oversize_warned_ = true;
ESP_LOGW(TAG,
"%s: Frame of %zu bytes is larger than %zu, dropped; check that both devices use the same "
"max_packet_size",
this->name_, length, this->max_packet_size_);
} else {
ESP_LOGV(TAG, "%s: Frame of %zu bytes is larger than %zu, dropped", this->name_, length, this->max_packet_size_);
}
this->note_received_(false);
return;
}
this->note_received_(true);
uint8_t command = control & ~POLL_FINAL_BIT;
if (command == UNNUMBERED_SABM) {
// A peer that has not heard our UA repeats its SABM, and cannot have sent a numbered frame meanwhile; answering
// again keeps this side connected, and the queued frames, instead of treating the repeat as a restart.
if (this->state_ == LinkState::LINK_STATE_CONNECTED && this->peer_frame_seen_) {
// The peer has restarted, so frames it has not acknowledged are lost
ESP_LOGD(TAG, "%s: Link reset by peer", this->name_);
this->start_reset_();
}
this->vr_ = 0;
this->rej_sent_ = false;
this->send_frame_(UNNUMBERED_UA, nullptr, 0);
// Both sides now start from 0, so the link is up even if our own SABM is unanswered
this->link_up_();
return;
}
if (command == UNNUMBERED_UA) {
if (this->state_ == LinkState::LINK_STATE_RESETTING)
this->link_up_();
return;
}
// Numbered frames are ignored until the link is reset; the peer answers our SABM first
if (this->state_ != LinkState::LINK_STATE_CONNECTED)
return;
this->peer_frame_seen_ = true;
uint8_t nr = control >> 5;
if ((control & 1) == 0) {
this->process_ack_(nr);
this->receive_info_(control, payload, length, meta_data);
return;
}
if ((control & 3) != SUPERVISORY_FRAME)
return;
this->process_ack_(nr);
if (((control >> 2) & 3) == SUPERVISORY_REJ && this->outstanding_() != 0) {
ESP_LOGV(TAG, "%s: Peer rejected from %u", this->name_, nr);
this->resend_outstanding_();
this->start_timer_();
}
}
void HdlcReliability::receive_info_(uint8_t control, const uint8_t *payload, size_t length,
const PacketMetaData &meta_data) {
uint8_t ns = (control >> 1) & 7;
if (ns != this->vr_) {
// A gap or a duplicate; ask once for a resend from the expected frame, then just acknowledge at once
if (!this->rej_sent_) {
this->rej_sent_ = this->send_supervisory_(SUPERVISORY_REJ);
if (this->rej_sent_)
return;
}
this->owe_ack_(true);
return;
}
this->vr_ = (this->vr_ + 1) & 7;
this->rej_sent_ = false;
// The acknowledgement is sent from loop(), so a reply sent from the callback can carry it instead
this->owe_ack_(false);
this->on_receive_from_interface_(PacketBuffer(payload, length), meta_data);
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,99 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_HDLC
#include <array>
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* A reliability layer that delivers packets in order, without loss or duplicates, between two peers.
*
* It follows HDLC asynchronous balanced mode, with sequence numbers modulo 8 and go-back-N retransmission:
* - I-frames carry a payload, the sequence number N(S) and an acknowledgement N(R) of the peer's frames.
* - RR frames acknowledge without a payload; REJ frames ask the peer to resend from N(R).
* - SABM resets both peers' sequence numbers, and UA answers it.
*
* Each frame is the HDLC control byte, the payload, then a CRC-16 of both in little endian order.
* Unacknowledged frames are resent after the timeout; after max_retries the link is reset.
*/
class HdlcReliability : public PacketInterface {
public:
/// Most frames that can be unacknowledged at once, set by the 3 bit sequence numbers.
static constexpr uint8_t MAX_WINDOW = 7;
/// Control byte plus CRC-16.
static constexpr size_t FRAME_OVERHEAD = 3;
explicit HdlcReliability(PacketInterface *lower);
void setup() override;
void loop() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
/// No room while the layer has no send queue: before setup(), or when setup() failed.
size_t get_send_capacity() const override {
return this->tx_slots_ == nullptr ? 0 : this->max_queue_length_ - this->outstanding_();
}
void set_ack_timeout(uint32_t timeout_ms) { this->timeout_ms_ = timeout_ms; }
void set_ack_delay(uint32_t ack_delay_ms) { this->ack_delay_ms_ = ack_delay_ms; }
void set_max_queue_length(uint8_t length) { this->max_queue_length_ = length; }
void set_max_retries(uint8_t retries) { this->max_retries_ = retries; }
bool is_connected() const { return this->state_ == LinkState::LINK_STATE_CONNECTED; }
protected:
enum class LinkState : uint8_t {
LINK_STATE_RESET_PENDING, // SABM to be sent from loop()
LINK_STATE_RESETTING, // SABM sent, waiting for UA
LINK_STATE_CONNECTED,
};
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
void receive_info_(uint8_t control, const uint8_t *payload, size_t length, const PacketMetaData &meta_data);
bool send_frame_(uint8_t control, const uint8_t *payload, size_t length);
bool send_supervisory_(uint8_t type);
bool send_pending_();
void resend_outstanding_();
void retry_send_();
/// An RR is owed: at once, or after the ack delay unless an I-frame carries it first.
void owe_ack_(bool now);
void ack_sent_();
void process_ack_(uint8_t nr);
void on_timeout_();
void start_reset_();
void link_up_();
void start_timer_();
void request_loop_();
uint8_t outstanding_() const { return (this->vs_ - this->va_) & 7; }
uint8_t slot_index_(uint8_t seq) const { return (this->head_ + ((seq - this->va_) & 7)) % this->max_queue_length_; }
uint8_t *slot_(uint8_t seq) { return this->tx_slots_ + this->slot_index_(seq) * this->max_packet_size_; }
PacketInterface *lower_;
// max_queue_length_ slots of max_packet_size_ bytes, a ring starting at head_ which holds frame va_; allocated in
// setup() and never freed
uint8_t *tx_slots_{nullptr};
std::array<uint16_t, MAX_WINDOW> tx_lengths_{}; // indexed like the slots
uint32_t timeout_ms_{200};
uint32_t ack_delay_ms_{0};
uint8_t max_queue_length_{4};
uint8_t max_retries_{5};
uint8_t retries_{0};
uint8_t head_{0};
uint8_t vs_{0}; // sequence number of the next new frame
uint8_t vn_{0}; // next frame to send; frames from va_ up to it have been sent
uint8_t va_{0}; // oldest unacknowledged frame
uint8_t vr_{0}; // next frame expected from the peer
LinkState state_{LinkState::LINK_STATE_RESET_PENDING};
bool ack_pending_{false}; // an RR is owed
bool ack_due_{false}; // and should be sent now
bool rej_sent_{false};
bool peer_frame_seen_{false}; // a numbered frame has arrived since the link came up
bool oversize_warned_{false};
bool reset_wait_logged_{false}; // the queue filled while waiting for this reset to be answered
};
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,155 @@
"""The kinds of layer that can be stacked on a packet interface, and the registry of their implementations.
Each module in this package implements one layer type and registers it with its kind, for example:
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA)
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
...
The packet_interface component imports every module here before it builds its schemas, so a new layer type needs
only a new module. The kinds, and the order in which they are stacked, are fixed here.
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_TYPE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
packet_interface_ns = cg.esphome_ns.namespace("packet_interface")
PacketInterface = packet_interface_ns.class_("PacketInterface", cg.Component)
CONF_RELIABILITY = "reliability"
CONF_SESSION = "session"
# Builds a layer above lower and returns it
LayerBuilder = Callable[[ConfigType, MockObj], Awaitable[Any]]
def _never(config: ConfigType) -> bool:
return False
def _nothing(config: ConfigType) -> list[str]:
return []
@dataclass(frozen=True)
class LayerType:
"""One implementation of a kind of layer."""
schema: Any
build: LayerBuilder
# True if this configuration works only when the peer sends packets back, which a session layer makes sure of
needs_session: Callable[[ConfigType], bool]
# The components this configuration needs loaded
auto_load: Callable[[ConfigType], list[str]]
class LayerKind:
"""A kind of layer, such as encryption, with the types that implement it."""
def __init__(
self, key: str, default_type: str, shorthand: str | None = None
) -> None:
self.key = key
self.default_type = default_type
# The option that a plain string value sets, as in `encryption: my key`
self.shorthand = shorthand
self.types: dict[str, LayerType] = {}
# Set once the schema is built, as types registered after that would not be in it
self.frozen = False
def register(
self,
name: str,
*,
schema: Any,
needs_session: Callable[[ConfigType], bool] = _never,
auto_load: Callable[[ConfigType], list[str]] = _nothing,
) -> Callable[[LayerBuilder], LayerBuilder]:
"""Register a type with its schema."""
def decorator(build: LayerBuilder) -> LayerBuilder:
if self.frozen:
raise ValueError(
f"{self.key} layer type '{name}' is registered after the schemas were built; register it "
"before the packet_interface component is imported"
)
if name in self.types:
raise ValueError(
f"{self.key} layer type '{name}' is already registered"
)
self.types[name] = LayerType(schema, build, needs_session, auto_load)
return build
return decorator
def _expand(self, value: Any) -> Any:
"""Allow a layer to be enabled with no options, as in `reliability:`, or with its shorthand."""
if value is None:
return {}
if self.shorthand is not None and isinstance(value, str):
return {self.shorthand: value}
return value
def schema(self) -> Callable[[Any], ConfigType]:
"""The schema for this kind; no type can be registered after this."""
self.frozen = True
return cv.All(
self._expand,
cv.typed_schema(
{name: layer.schema for name, layer in self.types.items()},
default_type=self.default_type,
),
)
def get_type(self, config: ConfigType) -> LayerType:
return self.types[config[CONF_TYPE]]
ENCRYPTION = LayerKind(CONF_ENCRYPTION, "xxtea", shorthand=CONF_KEY)
RELIABILITY = LayerKind(CONF_RELIABILITY, "hdlc")
SESSION = LayerKind(CONF_SESSION, "basic")
# The kinds stacked above the link, from the bottom up
STACK_ORDER = (ENCRYPTION, RELIABILITY, SESSION)
def stack_schema() -> dict:
"""Schema entries for the layers of one stack; call only once all layer types are registered."""
return {cv.Optional(kind.key): kind.schema() for kind in STACK_ORDER}
def check_session(config: ConfigType) -> None:
"""Layers that need packets back from the peer need a session layer, whose keep-alives send them."""
if CONF_SESSION in config:
return
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None and kind.get_type(
layer
).needs_session(layer):
raise cv.Invalid(
f"This '{kind.key}' needs packets from the peer, so it needs a '{CONF_SESSION}' layer, whose "
"keep-alives make sure they are sent",
path=[kind.key],
)
def stack_auto_load(config: ConfigType) -> set[str]:
"""The components that the layers configured in config need loaded."""
loads: set[str] = set()
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None:
loads.update(kind.get_type(layer).auto_load(layer))
return loads
async def build_stack(config: ConfigType, lower: MockObj) -> None:
"""Create the layers configured in config, from the bottom up, above lower."""
for kind in STACK_ORDER:
if (layer := config.get(kind.key)) is not None:
lower = await kind.get_type(layer).build(layer, lower)
@@ -0,0 +1,70 @@
"""Session layer that sends keep-alives and reports whether the peer can be reached."""
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BINARY_SENSOR,
CONF_ID,
CONF_TIMEOUT,
DEVICE_CLASS_CONNECTIVITY,
ENTITY_CATEGORY_DIAGNOSTIC,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import SESSION, PacketInterface, packet_interface_ns
CONF_KEEP_ALIVE = "keep_alive"
BasicSession = packet_interface_ns.class_("BasicSession", PacketInterface)
def _session_timeout(config: ConfigType) -> ConfigType:
"""Default the timeout to three keep-alive intervals, and require it to be longer than one."""
keep_alive = config[CONF_KEEP_ALIVE].total_milliseconds
if (timeout := config.get(CONF_TIMEOUT)) is None:
config[CONF_TIMEOUT] = cv.positive_time_period_milliseconds(
f"{keep_alive * 3}ms"
)
elif timeout.total_milliseconds <= keep_alive:
raise cv.Invalid("Must be longer than keep_alive", path=[CONF_TIMEOUT])
return config
BASIC_SESSION_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BasicSession),
cv.Optional(CONF_KEEP_ALIVE, default="10s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(hours=1)),
),
cv.Optional(CONF_TIMEOUT): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(hours=3)),
),
cv.Optional(CONF_BINARY_SENSOR): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_CONNECTIVITY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
).extend(cv.COMPONENT_SCHEMA),
_session_timeout,
)
@SESSION.register(
"basic",
schema=BASIC_SESSION_SCHEMA,
auto_load=lambda config: ["binary_sensor"] if CONF_BINARY_SENSOR in config else [],
)
async def build_basic_session(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
var = cg.new_Pvariable(config[CONF_ID], lower)
cg.add(var.set_keep_alive(config[CONF_KEEP_ALIVE]))
cg.add(var.set_session_timeout(config[CONF_TIMEOUT]))
await cg.register_component(var, config)
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_BINARY_SENSOR, var.set_connected_sensor)
return var
@@ -0,0 +1,59 @@
"""Reliability layer using HDLC style acknowledgement and go-back-N retransmission."""
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TIMEOUT
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import RELIABILITY, PacketInterface, packet_interface_ns
CONF_ACK_DELAY = "ack_delay"
CONF_MAX_QUEUE_LENGTH = "max_queue_length"
CONF_MAX_RETRIES = "max_retries"
HdlcReliability = packet_interface_ns.class_("HdlcReliability", PacketInterface)
def _ack_delay_within_timeout(config: ConfigType) -> ConfigType:
"""The peer, configured the same, must hear the acknowledgement before its timeout."""
if (
config[CONF_ACK_DELAY].total_milliseconds
>= config[CONF_TIMEOUT].total_milliseconds
):
raise cv.Invalid(
f"Must be shorter than '{CONF_TIMEOUT}'", path=[CONF_ACK_DELAY]
)
return config
HDLC_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(HdlcReliability),
# Retransmitting more often than the main loop runs would exhaust the retries at once
cv.Optional(CONF_TIMEOUT, default="200ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=50)),
),
cv.Optional(
CONF_ACK_DELAY, default="0ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_MAX_QUEUE_LENGTH, default=4): cv.int_range(1, 7),
cv.Optional(CONF_MAX_RETRIES, default=5): cv.int_range(1, 255),
}
).extend(cv.COMPONENT_SCHEMA),
_ack_delay_within_timeout,
)
@RELIABILITY.register("hdlc", schema=HDLC_SCHEMA)
async def build_hdlc(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_HDLC")
var = cg.new_Pvariable(config[CONF_ID], lower)
cg.add(var.set_ack_timeout(config[CONF_TIMEOUT]))
cg.add(var.set_ack_delay(config[CONF_ACK_DELAY]))
cg.add(var.set_max_queue_length(config[CONF_MAX_QUEUE_LENGTH]))
cg.add(var.set_max_retries(config[CONF_MAX_RETRIES]))
await cg.register_component(var, config)
return var
@@ -0,0 +1,50 @@
"""Encryption layer using XXTEA, with an optional rolling code."""
import hashlib
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_KEY
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import ENCRYPTION, PacketInterface, packet_interface_ns
CONF_ROLLING_CODE_ENABLE = "rolling_code_enable"
# A captured packet lets a guessed key be checked offline, so the key text is stretched to make each guess costly.
# Both ends derive the same key from the same text, so the salt is fixed; it keeps tables built for other uses of
# PBKDF2 from applying here.
KEY_SALT = b"esphome packet_interface xxtea"
KEY_ITERATIONS = 600_000
XxteaEncryption = packet_interface_ns.class_("XxteaEncryption", PacketInterface)
XXTEA_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(XxteaEncryption),
cv.Required(CONF_KEY): cv.All(cv.sensitive(cv.string), cv.Length(min=1)),
cv.Optional(CONF_ROLLING_CODE_ENABLE, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA)
def derive_key(text: str) -> list[int]:
"""The 256-bit key for the key text, as 32-bit words in little endian order."""
digest = hashlib.pbkdf2_hmac("sha256", text.encode(), KEY_SALT, KEY_ITERATIONS)
return [int.from_bytes(digest[i : i + 4], "little") for i in range(0, 32, 4)]
@ENCRYPTION.register(
"xxtea",
schema=XXTEA_SCHEMA,
# The rolling code accepts a packet only once the peer has sent back this device's boot number
needs_session=lambda config: config[CONF_ROLLING_CODE_ENABLE],
auto_load=lambda config: ["xxtea"],
)
async def build_xxtea(config: ConfigType, lower: MockObj) -> MockObj:
cg.add_define("USE_PACKET_INTERFACE_XXTEA")
var = cg.new_Pvariable(config[CONF_ID], lower, config[CONF_ROLLING_CODE_ENABLE])
cg.add(var.set_key(derive_key(config[CONF_KEY])))
await cg.register_component(var, config)
return var
@@ -0,0 +1,182 @@
#include "xxtea_encryption.h"
#ifdef USE_PACKET_INTERFACE_XXTEA
#include <algorithm>
#include <cstring>
#include "esphome/components/xxtea/xxtea.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::packet_interface {
ESPHOME_LOG_TAG(TAG, "packet_interface.xxtea");
static constexpr uint32_t PREF_HASH = fnv1a_hash("packet_interface.xxtea");
// Different for each mode, so devices set up differently drop each other's packets
static constexpr uint8_t CHECK_BYTES[3] = {0x58, 0x54, 0x45};
static constexpr uint8_t ROLLING_CHECK_BYTES[3] = {0x58, 0x54, 0x52};
static constexpr size_t CHECK_SIZE = 4;
// Boot number, counter, and the echo of the receiver's boot number
static constexpr size_t ROLLING_SIZE = 12;
// XXTEA needs at least two words
static constexpr size_t MIN_LENGTH = 8;
static constexpr size_t MAX_PADDING = 4;
static void put_uint32(uint8_t *buf, uint32_t value) {
buf[0] = value;
buf[1] = value >> 8;
buf[2] = value >> 16;
buf[3] = value >> 24;
}
static uint32_t get_uint32(const uint8_t *buf) { return encode_uint32(buf[3], buf[2], buf[1], buf[0]); }
static constexpr size_t trailer_size(bool rolling_code) {
return rolling_code ? ROLLING_SIZE + CHECK_SIZE : CHECK_SIZE;
}
XxteaEncryption::XxteaEncryption(PacketInterface *lower, bool rolling_code)
// XXTEA works on whole 32-bit words
: PacketInterface(remaining_packet_size(lower->get_max_packet_size() & ~size_t{3}, trailer_size(rolling_code))),
lower_(lower),
trailer_size_(trailer_size(rolling_code)),
rolling_code_(rolling_code) {
this->stack_on_(lower, CAPABILITY_ENCRYPTION);
}
void XxteaEncryption::set_key(std::initializer_list<uint32_t> key) {
std::copy_n(key.begin(), std::min(key.size(), this->key_.size()), this->key_.begin());
}
void XxteaEncryption::setup() {
if (this->max_packet_size_ == 0) {
this->mark_failed(LOG_STR("max_packet_size too small for the configured layers"));
return;
}
size_t words = this->lower_->get_max_packet_size() / 4;
RAMAllocator<uint32_t> allocator;
this->tx_buffer_ = allocator.allocate(words * 2);
if (this->tx_buffer_ == nullptr) {
this->mark_failed(LOG_STR("Not enough memory for the packet buffers"));
return;
}
this->rx_buffer_ = this->tx_buffer_ + words;
if (this->rolling_code_) {
this->pref_ = global_preferences->make_preference<uint32_t>(fnv1a_hash_extend(PREF_HASH, this->name_), true);
this->pref_.load(&this->boot_);
this->next_boot_();
}
this->lower_->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); });
}
void XxteaEncryption::dump_config() {
ESP_LOGCONFIG(TAG,
"XXTEA Encryption on '%s':\n"
" Max Packet Size: %zu\n"
" Rolling Code: %s",
this->name_, this->max_packet_size_, YESNO(this->rolling_code_));
if (this->rolling_code_) {
ESP_LOGCONFIG(TAG, " Boot Number: %" PRIu32, this->boot_);
}
}
// A new boot number must be saved before it is used, or a crash could make the peer see it again
void XxteaEncryption::next_boot_() {
this->boot_++;
this->counter_ = 0;
if (!this->pref_.save(&this->boot_) || !global_preferences->sync()) {
ESP_LOGW(TAG, "%s: Could not save the boot number, so a restart could reuse it", this->name_);
}
}
bool XxteaEncryption::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
if (this->tx_buffer_ == nullptr)
return false;
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
size_t padding = (4 - (size + this->trailer_size_) % 4) % 4;
if (size + padding + this->trailer_size_ < MIN_LENGTH)
padding += 4;
size_t length = size + padding + this->trailer_size_;
auto *buf = reinterpret_cast<uint8_t *>(this->tx_buffer_);
data.copy_to(buf, size);
memset(buf + size, 0, padding);
uint8_t *check = buf + length - CHECK_SIZE;
if (this->rolling_code_) {
put_uint32(check - ROLLING_SIZE, this->boot_);
put_uint32(check - ROLLING_SIZE + 4, this->counter_);
put_uint32(check - ROLLING_SIZE + 8, this->echo_boot_);
memcpy(check, ROLLING_CHECK_BYTES, sizeof(ROLLING_CHECK_BYTES));
if (++this->counter_ == 0)
this->next_boot_();
} else {
memcpy(check, CHECK_BYTES, sizeof(CHECK_BYTES));
}
check[CHECK_SIZE - 1] = padding;
xxtea::encrypt(this->tx_buffer_, length / 4, this->key_.data());
return this->lower_->send_to_interface(PacketBuffer(buf, length), meta_data);
}
bool XxteaEncryption::accept_counter_(uint32_t boot, uint32_t counter) {
if (this->peer_seen_ && (boot < this->peer_boot_ || (boot == this->peer_boot_ && counter <= this->peer_counter_)))
return false;
this->peer_seen_ = true;
this->peer_boot_ = boot;
this->peer_counter_ = counter;
return true;
}
void XxteaEncryption::receive_(const PacketBuffer &data, const PacketMetaData &meta_data) {
size_t length = data.size();
if (length % 4 != 0 || length < std::max(MIN_LENGTH, this->trailer_size_) ||
length > this->lower_->get_max_packet_size()) {
ESP_LOGV(TAG, "%s: Packet of %zu bytes dropped", this->name_, length);
this->note_received_(false);
return;
}
auto *buf = reinterpret_cast<uint8_t *>(this->rx_buffer_);
data.copy_to(buf, length);
xxtea::decrypt(this->rx_buffer_, length / 4, this->key_.data());
const uint8_t *check = buf + length - CHECK_SIZE;
const uint8_t *expected = this->rolling_code_ ? ROLLING_CHECK_BYTES : CHECK_BYTES;
size_t padding = check[CHECK_SIZE - 1];
if (memcmp(check, expected, sizeof(CHECK_BYTES)) != 0 || padding > MAX_PADDING ||
padding > length - this->trailer_size_) {
ESP_LOGV(TAG, "%s: Packet failed authentication", this->name_);
this->note_received_(false);
return;
}
this->note_received_(true);
if (this->rolling_code_) {
uint32_t boot = get_uint32(check - ROLLING_SIZE);
uint32_t counter = get_uint32(check - ROLLING_SIZE + 4);
uint32_t echo = get_uint32(check - ROLLING_SIZE + 8);
// Learn the peer's boot number even from a packet that is then dropped, so both sides can catch up
if (boot > this->echo_boot_)
this->echo_boot_ = boot;
// A packet recorded before this boot cannot carry this boot's number
if (echo != this->boot_) {
ESP_LOGV(TAG, "%s: Packet for boot %" PRIu32 " dropped", this->name_, echo);
return;
}
if (!this->accept_counter_(boot, counter)) {
// A boot number lower than one already heard means the peer lost its saved boot number, or a replay
if (boot < this->peer_boot_ && !this->old_boot_warned_) {
this->old_boot_warned_ = true;
ESP_LOGW(TAG,
"%s: Packets from an older boot %" PRIu32 " of the peer are dropped; if its flash was erased, "
"restart this device",
this->name_, boot);
}
ESP_LOGV(TAG, "%s: Old packet %" PRIu32 ":%" PRIu32 " dropped", this->name_, boot, counter);
return;
}
}
this->on_receive_from_interface_(PacketBuffer(buf, length - this->trailer_size_ - padding), meta_data);
}
} // namespace esphome::packet_interface
#endif
@@ -0,0 +1,61 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PACKET_INTERFACE_XXTEA
#include <array>
#include <initializer_list>
#include "esphome/core/preferences.h"
#include "packet_interface.h"
namespace esphome::packet_interface {
/**
* An encryption layer that encrypts and authenticates each packet with XXTEA and a 256-bit shared key.
*
* The packet is padded with zeros and followed by a trailer, then the whole is encrypted as one block. With the
* rolling code enabled, the trailer starts with:
* - a 32-bit boot number, saved in flash and increased at every boot;
* - a 32-bit packet counter, starting at 0 at every boot;
* - the highest boot number heard from the peer, echoed back to it.
* The trailer always ends with 3 fixed check bytes and the number of padding bytes. XXTEA mixes the whole block, so
* a packet that was changed or encrypted with another key fails the check.
* With the rolling code, a packet is dropped as a replay unless it echoes this device's current boot number, and its
* boot number and counter are higher than those of the last packet accepted from the peer. A boot number is never
* reused, so the echo acts as a fresh challenge after every restart.
*/
class XxteaEncryption : public PacketInterface {
public:
XxteaEncryption(PacketInterface *lower, bool rolling_code);
void setup() override;
void dump_config() override;
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) override;
void set_key(std::initializer_list<uint32_t> key);
protected:
void receive_(const PacketBuffer &data, const PacketMetaData &meta_data);
bool accept_counter_(uint32_t boot, uint32_t counter);
void next_boot_();
PacketInterface *lower_;
const size_t trailer_size_;
std::array<uint32_t, 8> key_{};
// Separate buffers, as a layer above may send a reply while a received packet is being passed up; both are in one
// block allocated in setup(), and tx_buffer_ stays null if that fails
uint32_t *tx_buffer_{nullptr};
uint32_t *rx_buffer_{nullptr};
ESPPreferenceObject pref_;
uint32_t boot_{0};
uint32_t counter_{0};
uint32_t peer_boot_{0};
uint32_t peer_counter_{0};
uint32_t echo_boot_{0}; // highest boot number heard from the peer
bool peer_seen_{false};
bool old_boot_warned_{false};
const bool rolling_code_;
};
} // namespace esphome::packet_interface
#endif
@@ -3,6 +3,7 @@
#ifdef USE_RUNTIME_IMAGE_BMP
#include "esphome/components/display/display.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -12,6 +13,9 @@ namespace esphome::runtime_image {
ESPHOME_LOG_TAG(TAG, "image_decoder.bmp");
// BMP has no alpha. Every pixel is drawn opaque, or a transparent image would hide it
static constexpr Color OPAQUE_BLACK(0, 0, 0, 0xFF);
void BmpDecoder::reset() {
ImageDecoder::reset();
this->bits_per_pixel_ = 0;
@@ -142,23 +146,31 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
this->current_index_ = this->data_offset_;
index = this->data_offset_;
}
// A call that has run for DECODE_TIME_LIMIT_MS stops, and the caller passes the rest again later
const uint32_t start = millis();
uint32_t steps = 0;
auto out_of_time = [start, &steps]() {
return ++steps % DECODE_TIME_CHECK_STEPS == 0 && millis() - start >= DECODE_TIME_LIMIT_MS;
};
// Bytes after the last pixel are used up without drawing anything
const size_t total_pixels = static_cast<size_t>(this->width_) * this->height_;
switch (this->bits_per_pixel_) {
case 1: {
size_t width = static_cast<size_t>(this->width_);
while (index < size) {
while (index < size && this->paint_index_ < total_pixels) {
size_t x = this->paint_index_ % width;
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / width);
size_t remaining_in_row = width - x;
uint8_t pixels_in_byte = std::min<size_t>(remaining_in_row, 8);
bool end_of_row = remaining_in_row <= 8;
size_t needed = 1 + (end_of_row ? this->padding_bytes_ : 0);
if (index + needed > size) {
if (index + needed > size || out_of_time()) {
this->decoded_bytes_ += index;
return index;
}
uint8_t current_byte = buffer[index];
for (uint8_t i = 0; i < pixels_in_byte; i++) {
Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF;
Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : OPAQUE_BLACK;
this->draw(x + i, y, 1, 1, c);
}
this->paint_index_ += pixels_in_byte;
@@ -175,11 +187,11 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
case 8: {
size_t width = static_cast<size_t>(this->width_);
size_t last_col = width - 1;
while (index < size) {
while (index < size && this->paint_index_ < total_pixels) {
size_t x = this->paint_index_ % width;
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / width);
size_t needed = 1 + ((x == last_col) ? this->padding_bytes_ : 0);
if (index + needed > size) {
if (index + needed > size || out_of_time()) {
this->decoded_bytes_ += index;
return index;
}
@@ -194,7 +206,7 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
uint8_t b = rgb & 0xff;
uint8_t g = (rgb >> 8) & 0xff;
uint8_t r = (rgb >> 16) & 0xff;
this->draw(x, y, 1, 1, Color(r, g, b));
this->draw(x, y, 1, 1, Color(r, g, b, 0xFF));
this->paint_index_++;
this->current_index_++;
index++;
@@ -208,18 +220,18 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
case 24: {
size_t width = static_cast<size_t>(this->width_);
size_t last_col = width - 1;
while (index < size) {
while (index < size && this->paint_index_ < total_pixels) {
size_t x = this->paint_index_ % width;
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / width);
size_t needed = 3 + ((x == last_col) ? this->padding_bytes_ : 0);
if (index + needed > size) {
if (index + needed > size || out_of_time()) {
this->decoded_bytes_ += index;
return index;
}
uint8_t b = buffer[index];
uint8_t g = buffer[index + 1];
uint8_t r = buffer[index + 2];
this->draw(x, y, 1, 1, Color(r, g, b));
this->draw(x, y, 1, 1, Color(r, g, b, 0xFF));
this->paint_index_++;
this->current_index_ += 3;
index += 3;
@@ -1,29 +1,106 @@
#include "image_decoder.h"
#include "runtime_image.h"
#include "esphome/components/display/display_color_utils.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::runtime_image {
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;
return success;
if (!this->resize_image_(width, height))
return false;
this->prepare_draw_(width, height);
return true;
}
bool ImageDecoder::resize_image_(int width, int height) {
this->size_valid_ = this->image_->resize(width, height) > 0;
return this->size_valid_;
}
void ImageDecoder::prepare_draw_(int width, int height) {
this->draw_width_ = width;
this->draw_height_ = height;
this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency();
if (!this->direct_rgb565_)
return;
if ((this->x_boundaries_.capacity() < static_cast<size_t>(width + 1) && !this->x_boundaries_.try_init(width + 1)) ||
(this->y_boundaries_.capacity() < static_cast<size_t>(height + 1) && !this->y_boundaries_.try_init(height + 1))) {
ESP_LOGW(TAG, "No memory to draw straight into the buffer; drawing pixel by pixel");
this->direct_rgb565_ = false;
// Free the table that did fit, as nothing uses it
this->x_boundaries_.release();
this->y_boundaries_.release();
return;
}
this->x_boundaries_.clear();
this->y_boundaries_.clear();
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
// The same boundaries as draw() uses, so both paths paint the same pixels
for (int x = 0; x <= width; x++) {
this->x_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(x, width, buffer_width)));
}
for (int y = 0; y <= height; y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(y, height, buffer_height)));
}
}
void ImageDecoder::draw_rgb565_(int x, int y, int w, int h, uint16_t rgb565) {
const int x_end = std::min(x + w, this->draw_width_);
const int y_end = std::min(y + h, this->draw_height_);
x = std::max(x, 0);
y = std::max(y, 0);
if (x >= x_end || y >= y_end)
return;
// A block of source pixels lands on one block of the buffer
const uint16_t x1 = this->x_boundaries_[x];
const uint16_t x2 = this->x_boundaries_[x_end];
const uint16_t y1 = this->y_boundaries_[y];
const uint16_t y2 = this->y_boundaries_[y_end];
const bool big_endian = this->image_is_big_endian_();
const uint8_t first = big_endian ? rgb565 >> 8 : rgb565 & 0xFF;
const uint8_t second = big_endian ? rgb565 & 0xFF : rgb565 >> 8;
uint8_t *const buffer = this->image_buffer_();
const uint32_t buffer_width = this->image_->get_buffer_width();
for (uint32_t row = y1; row < y2; row++) {
uint8_t *destination = buffer + (row * buffer_width + x1) * 2;
for (uint32_t col = x1; col < x2; col++) {
destination[0] = first;
destination[1] = second;
destination += 2;
}
}
}
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++) {
if (this->direct_rgb565_) {
this->draw_rgb565_(x, y, w, h, display::ColorUtil::color_to_565(color));
return;
}
// When shrinking, each target pixel is painted once, by the first source pixel that reaches it, rather than by every
// pixel that lands on it. Integer maths: the S3 has no double FPU.
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
int x1 = x;
int y1 = y;
int x2 = x + w;
int y2 = y + h;
if (buffer_width != this->draw_width_ || buffer_height != this->draw_height_) {
x1 = scale_boundary(x, this->draw_width_, buffer_width);
y1 = scale_boundary(y, this->draw_height_, buffer_height);
x2 = scale_boundary(x + w, this->draw_width_, buffer_width);
y2 = scale_boundary(y + h, this->draw_height_, buffer_height);
}
x2 = std::min(buffer_width, x2);
y2 = std::min(buffer_height, y2);
for (int i = x1; i < x2; i++) {
for (int j = y1; j < y2; j++) {
this->image_->draw_pixel(i, j, color);
}
}
@@ -2,10 +2,20 @@
#include <cstdint>
#include "esphome/core/color.h"
#include "esphome/core/helpers.h"
#include "image_format.h"
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,
@@ -38,6 +48,11 @@ constexpr const char *decode_error_to_string(int error) {
}
}
// The longest one decode() call works before it returns, leaving the rest of the data for the next call
static constexpr uint32_t DECODE_TIME_LIMIT_MS = 15;
// Pixel loops read the clock once per this many steps
static constexpr uint32_t DECODE_TIME_CHECK_STEPS = 64;
class RuntimeImage;
/// Input that a decoder reads itself as it goes, so the whole image never has to be in memory at once
@@ -83,8 +98,9 @@ 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;
this->direct_rgb565_ = false;
}
/**
@@ -179,19 +195,37 @@ class ImageDecoder {
/// Whether is_finished() can tell a complete image from a cut-off one
virtual bool knows_end() const { return this->expected_size_ > 0; }
/// The image is opaque RGB565, so draw() writes it straight into the buffer
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
protected:
/// Resize the image for a width x height source, without preparing to draw. prepare_draw_() must follow.
bool resize_image_(int width, int height);
/**
* Draw a source of width x height over the whole buffer from now on. For an opaque RGB565 image this builds the
* tables of where each source column and row lands; without the memory for them it draws pixel by pixel instead.
*/
void prepare_draw_(int width, int height);
/// Paint a block of source pixels in one color straight into the buffer. Only while uses_direct_rgb565().
void draw_rgb565_(int x, int y, int w, int h, uint16_t rgb565);
/// The image's pixel buffer, for decoders that write their output straight into it
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;
// Boundary n is the first buffer column, or row, that source column, or row, n paints
FixedVector<uint16_t> x_boundaries_{};
FixedVector<uint16_t> y_boundaries_{};
RuntimeImage *image_;
size_t expected_size_ = 0; // Expected data size (0 if unknown)
size_t decoded_bytes_ = 0; // Bytes processed so far
const ImageFormat format_;
bool active_ = false; // A decoding session is in progress
bool size_valid_ = true; // Last set_size() result; draw() no-ops while false
bool direct_rgb565_ = false;
};
} // namespace esphome::runtime_image
@@ -16,32 +16,6 @@ ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg");
namespace esphome::runtime_image {
bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
if (this->x_boundaries_.capacity() < static_cast<size_t>(width + 1) && !this->x_boundaries_.try_init(width + 1)) {
return false;
}
if (this->y_boundaries_.capacity() < static_cast<size_t>(height + 1) && !this->y_boundaries_.try_init(height + 1)) {
return false;
}
this->source_width_ = width;
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));
}
for (uint32_t y = 0; y <= static_cast<uint32_t>(height); y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>((y * target_height + height - 1) / height));
}
return true;
}
void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) {
if (pixels == nullptr) {
return;
@@ -52,14 +26,14 @@ void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels)
const uint8_t first = this->image_is_big_endian_() ? 1 : 0;
for (int source_y = 0; source_y < h; source_y++) {
const int absolute_y = y + source_y;
if (absolute_y < 0 || absolute_y >= this->source_height_) {
if (absolute_y < 0 || absolute_y >= this->draw_height_) {
continue;
}
const uint16_t y1 = this->y_boundaries_[absolute_y];
const uint16_t y2 = this->y_boundaries_[absolute_y + 1];
for (int source_x = 0; source_x < w; source_x++) {
const int absolute_x = x + source_x;
if (absolute_x < 0 || absolute_x >= this->source_width_) {
if (absolute_x < 0 || absolute_x >= this->draw_width_) {
continue;
}
const uint16_t x1 = this->x_boundaries_[absolute_x];
@@ -157,17 +131,10 @@ int JpegDecoder::start_() {
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
this->jpeg_.setUserPointer(this);
this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency();
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_.setPixelType(RGB8888);
}
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
if (!this->set_size(width, height)) {
// The tables for drawing are only built once the decoded size is known, below
if (!this->resize_image_(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
@@ -181,13 +148,16 @@ int JpegDecoder::start_() {
}
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
this->x_scale_ = static_cast<double>(buffer_width) / decoded_width;
this->y_scale_ = static_cast<double>(buffer_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;
this->prepare_draw_(decoded_width, decoded_height);
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_.setPixelType(RGB8888);
}
return 0;
}
@@ -30,13 +30,11 @@ class JpegDecoder : public ImageDecoder {
/// Decodes one row of MCUs (8 or 16 pixel rows), or waits part way through one for more of the source
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
@@ -49,13 +47,8 @@ class JpegDecoder : public ImageDecoder {
static void source_close(void *handle) {}
JPEGDEC jpeg_{};
FixedVector<uint16_t> x_boundaries_{};
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};
};
@@ -6,10 +6,15 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
ESPHOME_LOG_TAG(TAG, "image_decoder.png");
namespace esphome::runtime_image {
// Compressed bytes handed to pngle at a time
static constexpr size_t FEED_PIECE_SIZE = 64;
/**
* @brief Callback method that will be called by the PNGLE engine when the basic
* data of the image is received (i.e. width and height);
@@ -93,16 +98,26 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
ESP_LOGD(TAG, "Waiting for more data");
return 0;
}
auto fed = pngle_feed(this->pngle_, buffer, size);
if (fed < 0) {
ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_));
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
} else if (!this->size_valid_) {
return DECODE_ERROR_OUT_OF_MEMORY;
} else {
// A few compressed bytes can hold many rows of pixels, so feed small pieces and stop once the call has run for
// DECODE_TIME_LIMIT_MS; the caller passes the rest again later
const uint32_t start = millis();
size_t total = 0;
while (total < size) {
const size_t piece = std::min(FEED_PIECE_SIZE, size - total);
const int fed = pngle_feed(this->pngle_, buffer + total, piece);
if (fed < 0) {
ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_));
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
if (!this->size_valid_)
return DECODE_ERROR_OUT_OF_MEMORY;
this->decoded_bytes_ += fed;
total += fed;
// pngle takes less than the piece when a chunk header or CRC runs past its end; that is no reason to stop
if (fed == 0 || millis() - start >= DECODE_TIME_LIMIT_MS)
break;
}
return fed;
return total;
}
} // namespace esphome::runtime_image
@@ -3,9 +3,11 @@
#ifdef USE_RUNTIME_IMAGE_QOI
#include "esphome/components/display/display.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cinttypes>
namespace esphome::runtime_image {
@@ -102,12 +104,17 @@ int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
Color color;
const size_t total_pixels = this->width_ * this->height_;
// A call that has run for DECODE_TIME_LIMIT_MS stops, and the caller passes the rest again later
const uint32_t start = millis();
uint32_t steps = 0;
while (index < size && this->paint_index_ < total_pixels) {
if (++steps % DECODE_TIME_CHECK_STEPS == 0 && millis() - start >= DECODE_TIME_LIMIT_MS)
break;
color = this->last_pixel_;
uint8_t byte = buffer[index];
if (byte == QOI_OP_RGB) {
if (size < index + QOI_RGB_CHUNK_SIZE) {
return index; // Need more data for RGB chunk
break; // Need more data for RGB chunk
}
index++;
color.r = buffer[index++];
@@ -117,7 +124,7 @@ int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
this->paint_index_++;
} else if (byte == QOI_OP_RGBA) {
if (size < index + QOI_RGBA_CHUNK_SIZE) {
return index; // Need more data for RGBA chunk
break; // Need more data for RGBA chunk
}
index++;
color.r = buffer[index++];
@@ -129,15 +136,18 @@ int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
} else if ((byte & QOI_MASK_OP) == QOI_OP_RUN) {
// QOI run chunk
size_t run_length = (byte & QOI_MASK_VALUE) + 1; // run length is encoded in the lower 6 bits, plus one
for (size_t i = 0; i < run_length; i++) {
// TODO: optimize by drawing runs of pixels at once instead of one by one
this->draw(this->paint_index_ % this->width_, this->paint_index_ / this->width_, 1, 1, color);
this->paint_index_++;
// Draw the part of the run in each row it covers at once
while (run_length > 0) {
const size_t x = this->paint_index_ % this->width_;
const size_t count = std::min(run_length, this->width_ - x);
this->draw(x, this->paint_index_ / this->width_, count, 1, color);
this->paint_index_ += count;
run_length -= count;
}
index++;
} else if ((byte & QOI_MASK_OP) == QOI_OP_LUMA) {
if (size < index + QOI_LUMA_CHUNK_SIZE) {
return index; // Need more data for LUMA chunk
break; // Need more data for LUMA chunk
}
index++;
uint8_t byte2 = buffer[index++];
@@ -165,8 +175,10 @@ int HOT QoiDecoder::decode(uint8_t *buffer, size_t size) {
this->last_pixel_ = color;
this->color_table_[qoi_color_table_index(color)] = color;
}
this->decoded_bytes_ += size;
return size;
if (this->paint_index_ >= total_pixels)
index = size; // All that follows the last pixel is the end marker
this->decoded_bytes_ += index;
return index;
}
} // namespace esphome::runtime_image
@@ -113,7 +113,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
}
case image::IMAGE_TYPE_GRAYSCALE: {
uint32_t pos = this->get_position_(x, y);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
// 0.2125, 0.7154 and 0.0721 in 1/256ths, summing to 256 so white stays 255. Integer maths: the S3 has no
// double FPU
auto gray = static_cast<uint8_t>((54 * color.r + 183 * color.g + 19 * color.b) >> 8);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
@@ -0,0 +1,40 @@
import esphome.codegen as cg
from esphome.components import packet_interface
from esphome.components.packet_interface import (
CONF_MAX_PACKET_SIZE,
new_packet_interface,
packet_interface_schema,
)
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_UDP_ID, UDP_SCHEMA, udp_ns
CODEOWNERS = ["@clydebarrow"]
DEPENDENCIES = ["udp"]
# Size of the receive buffer of the udp component
MAX_PACKET_SIZE = 508
UdpPacketInterface = udp_ns.class_(
"UdpPacketInterface",
packet_interface.PacketInterface,
)
CONFIG_SCHEMA = packet_interface_schema(
UdpPacketInterface, MAX_PACKET_SIZE, MAX_PACKET_SIZE
).extend(UDP_SCHEMA)
async def to_code(config: ConfigType) -> None:
udp_var = await cg.get_variable(config[CONF_UDP_ID])
cg.add(udp_var.set_should_listen())
# The udp component sends to its addresses through its broadcast socket
cg.add(udp_var.set_should_broadcast())
# The copy buffer's size is known here, so it is a static array rather than allocated at run time
tx_buffer = f"{config[CONF_ID].id}__tx_buffer"
cg.add_global(
cg.RawStatement(f"static uint8_t {tx_buffer}[{config[CONF_MAX_PACKET_SIZE]}];")
)
await new_packet_interface(config, udp_var, cg.RawExpression(tx_buffer))
@@ -0,0 +1,43 @@
#include "udp_packet_interface.h"
#ifdef USE_NETWORK
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
namespace esphome::udp {
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
ESPHOME_LOG_TAG(TAG, "udp.packet_interface");
void UdpPacketInterface::setup() {
this->parent_->add_listener([this](std::span<const uint8_t> data) { this->receive_(data); });
}
void UdpPacketInterface::receive_(std::span<const uint8_t> data) {
if (data.size() > this->max_packet_size_) {
ESP_LOGV(TAG, "%s: Packet of %zu bytes is larger than %zu, dropped", this->name_, data.size(),
this->max_packet_size_);
return;
}
this->on_receive_from_interface_(PacketBuffer(data.data(), data.size()), {});
}
bool UdpPacketInterface::send_to_interface(const PacketBuffer &data, const PacketMetaData &meta_data) {
size_t size = data.size();
if (size > this->max_packet_size_) {
ESP_LOGW(TAG, "%s: Packet of %zu bytes is larger than %zu", this->name_, size, this->max_packet_size_);
return false;
}
if (!network::is_connected())
return false;
if (data.is_contiguous()) {
auto span = data.as_span();
return this->parent_->send_packet(span.data(), span.size());
}
data.copy_to(this->tx_buffer_.data(), size);
return this->parent_->send_packet(this->tx_buffer_.data(), size);
}
} // namespace esphome::udp
#endif
@@ -0,0 +1,37 @@
#pragma once
#include "esphome/core/defines.h"
// udp_component.h declares nothing without USE_NETWORK
#ifdef USE_NETWORK
#include <span>
#include "esphome/components/packet_interface/packet_interface.h"
#include "../udp_component.h"
namespace esphome::udp {
/**
* A packet interface link over UDP: each packet is one datagram, sent to every address of the UDP component.
*
* Every datagram received on the listen port is passed up, from any sender, so on a shared network an addressing
* or encryption layer is needed to tell the peer's packets from others.
*/
class UdpPacketInterface : public packet_interface::PacketInterface {
public:
/// @param tx_buffer Joins packets split across buffers; its size is the largest packet, and it must outlive the link
UdpPacketInterface(UDPComponent *udp_component, std::span<uint8_t> tx_buffer)
: packet_interface::PacketInterface(tx_buffer.size()), parent_(udp_component), tx_buffer_(tx_buffer) {}
void setup() override;
bool send_to_interface(const packet_interface::PacketBuffer &data,
const packet_interface::PacketMetaData &meta_data) override;
protected:
void receive_(std::span<const uint8_t> data);
UDPComponent *parent_;
const std::span<uint8_t> tx_buffer_;
};
} // namespace esphome::udp
#endif
@@ -1,12 +1,9 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/components/network/util.h"
#include "udp_transport.h"
namespace esphome::udp {
ESPHOME_LOG_TAG(TAG, "udp_transport");
bool UDPTransport::should_send() { return network::is_connected(); }
void UDPTransport::setup() {
PacketTransport::setup();
+34 -14
View File
@@ -205,18 +205,25 @@ void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) {
}
void UDPComponent::loop() {
if (this->should_listen_) {
std::array<uint8_t, MAX_PACKET_SIZE> buf;
for (;;) {
if (!this->listen_socket_->ready())
break;
auto len = this->listen_socket_->read(buf.data(), buf.size());
if (len <= 0)
break;
size_t packet_len = static_cast<size_t>(len);
ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
if (!this->should_listen_) {
this->disable_loop();
return;
}
// One byte more than the largest packet, as a read cuts a longer datagram to fit and so cannot report it
std::array<uint8_t, MAX_PACKET_SIZE + 1> buf;
for (;;) {
if (!this->listen_socket_->ready())
break;
auto len = this->listen_socket_->read(buf.data(), buf.size());
if (len <= 0)
break;
size_t packet_len = static_cast<size_t>(len);
if (packet_len > MAX_PACKET_SIZE) {
ESP_LOGV(TAG, "Packet longer than %zu bytes dropped", MAX_PACKET_SIZE);
continue;
}
ESP_LOGV(TAG, "Received packet of length %zu", packet_len);
this->packet_listeners_.call(std::span<const uint8_t>(buf.data(), packet_len));
}
}
@@ -239,9 +246,10 @@ void UDPComponent::dump_config() {
YESNO(this->should_broadcast_), YESNO(this->should_listen_));
}
void UDPComponent::send_packet(const uint8_t *data, size_t size) {
bool UDPComponent::send_packet(const uint8_t *data, size_t size) {
if (this->is_failed())
return; // a send socket may be missing
return false; // a send socket may be missing
bool sent = true;
for (const auto &entry : this->sockaddrs_) {
#ifdef USE_UDP_IPV6_SEND
const bool v6 = entry.sa.sa_family == AF_INET6;
@@ -256,11 +264,23 @@ void UDPComponent::send_packet(const uint8_t *data, size_t size) {
socket::UDPSendSocket *sock = this->broadcast_socket_.get();
socklen_t sockaddr_size = sizeof(sockaddr_in);
#endif
if (sock == nullptr) {
ESP_LOGW(TAG, "Broadcast socket not initialized");
return false;
}
auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size);
if (result < 0) {
ESP_LOGW(TAG, "sendto() error %d", errno);
// lwIP refuses a packet with ENOMEM while the destination's ARP lookup is pending and its queue is full;
// the packet can be sent again later
if (errno == ENOMEM) {
ESP_LOGV(TAG, "sendto() out of memory");
} else {
ESP_LOGW(TAG, "sendto() error %d", errno);
}
sent = false;
}
}
return sent;
}
} // namespace esphome::udp
+4 -3
View File
@@ -35,8 +35,9 @@ class UDPComponent final : public Component {
void setup() override;
void loop() override;
void dump_config() override;
void send_packet(const uint8_t *data, size_t size);
void send_packet(const std::vector<uint8_t> &buf) { this->send_packet(buf.data(), buf.size()); }
/// Returns false if the packet could not be sent to every destination.
bool send_packet(const uint8_t *data, size_t size);
bool send_packet(const std::vector<uint8_t> &buf) { return this->send_packet(buf.data(), buf.size()); }
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; };
protected:
@@ -53,7 +54,7 @@ class UDPComponent final : public Component {
bool network_poll_();
void set_poll_warning_(PollStep step, const LogString *what);
CallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
LazyCallbackManager<void(std::span<const uint8_t>)> packet_listeners_{};
std::unique_ptr<socket::UDPSendSocket> broadcast_socket_ = nullptr;
std::unique_ptr<socket::UDPSocket> listen_socket_ = nullptr;
#ifdef USE_UDP_IPV6_SEND
+3
View File
@@ -273,6 +273,9 @@
#define USE_OTA_STATE_LISTENER
#define USE_OUTPUT
#define USE_OUTPUT_FLOAT_POWER_SCALING
#define USE_PACKET_INTERFACE_BASIC_SESSION
#define USE_PACKET_INTERFACE_HDLC
#define USE_PACKET_INTERFACE_XXTEA
#define USE_PCA9554_PCAL
#define USE_POWER_SUPPLY
// Only defined by key-lookup preference backends; the slot-based platforms
+88 -30
View File
@@ -5,6 +5,7 @@ from datetime import datetime
import json
import logging
from pathlib import Path
import re
import ssl
import tempfile
import time
@@ -23,6 +24,7 @@ from esphome.const import (
CONF_LOG_TOPIC,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_PASSWORD,
CONF_PAYLOAD,
CONF_PORT,
@@ -34,6 +36,7 @@ from esphome.const import (
)
from esphome.core import EsphomeError
from esphome.helpers import get_int_env, get_str_env
from esphome.mqtt_topics import per_board_suffix
from esphome.types import ConfigType
from esphome.util import safe_print
@@ -394,48 +397,101 @@ def show_logs(
password: str | None = None,
client_id: str | None = None,
) -> int:
if topic is not None:
pass # already have topic
elif CONF_MQTT in config:
conf = config[CONF_MQTT]
if CONF_LOG_TOPIC in conf:
if config[CONF_MQTT][CONF_LOG_TOPIC] is None:
_LOGGER.error("MQTT log topic set to null, can't start MQTT logs")
return 1
if CONF_TOPIC not in config[CONF_MQTT][CONF_LOG_TOPIC]:
_LOGGER.error("MQTT log topic not available, can't start MQTT logs")
return 1
topic = config[CONF_MQTT][CONF_LOG_TOPIC][CONF_TOPIC]
elif CONF_TOPIC_PREFIX in config[CONF_MQTT]:
topic = f"{config[CONF_MQTT][CONF_TOPIC_PREFIX]}/debug"
# Where the device publishes its logs, also needed to match a topic given on the command line
config_topic: str | None = None
error: str | None = None
mqtt_conf = config.get(CONF_MQTT, {})
if CONF_MQTT not in config:
error = "MQTT isn't setup, can't start MQTT logs"
elif CONF_LOG_TOPIC in mqtt_conf:
if mqtt_conf[CONF_LOG_TOPIC] is None:
error = "MQTT log topic set to null, can't start MQTT logs"
elif CONF_TOPIC not in mqtt_conf[CONF_LOG_TOPIC]:
error = "MQTT log topic not available, can't start MQTT logs"
else:
topic = f"{config[CONF_ESPHOME][CONF_NAME]}/debug"
config_topic = mqtt_conf[CONF_LOG_TOPIC][CONF_TOPIC]
elif CONF_TOPIC_PREFIX in mqtt_conf:
config_topic = f"{mqtt_conf[CONF_TOPIC_PREFIX]}/debug"
else:
_LOGGER.error("MQTT isn't setup, can't start MQTT logs")
return 1
_LOGGER.info("Starting log output from %s", topic)
logs_topic = f"esphome/logs/{config[CONF_ESPHOME][CONF_NAME]}"
config_topic = f"{config[CONF_ESPHOME][CONF_NAME]}/debug"
# A topic given on the command line is followed as written
topic_from_cli = topic is not None
if not topic_from_cli:
if error is not None:
_LOGGER.error(error)
return 1
topic = config_topic
esphome_conf = config[CONF_ESPHOME]
name = esphome_conf[CONF_NAME]
# With the MAC suffix every board listens on esphome/logs/<name>-<mac>, which only its birth message names
mac_suffix = esphome_conf.get(CONF_NAME_ADD_MAC_SUFFIX, False)
# Ask when the retained birth message reports online: a request sent while the device is down is
# lost, and only a retained message tells a CLI that connects later that the device is already up.
# An empty payload deletes the retained message instead, so it counts as no birth message.
birth = config.get(CONF_MQTT, {}).get(CONF_BIRTH_MESSAGE) or {}
birth = mqtt_conf.get(CONF_BIRTH_MESSAGE) or {}
birth_payload = birth.get(CONF_PAYLOAD)
birth_topic = birth.get(CONF_TOPIC) if birth_payload else None
ask_on_connect = birth_topic is None or not birth[CONF_RETAIN]
ask_on_connect = not mac_suffix and (birth_topic is None or not birth[CONF_RETAIN])
def request_crash_log(client: mqtt.Client) -> None:
def per_board(present: str | None, key: str) -> re.Pattern[str] | None:
# Topics the firmware builds from <name>-<mac>, which the CLI cannot know
if present is None or not (
suffix := per_board_suffix(mqtt_conf, key, name, mac_suffix)
):
return None
return re.compile(rf"({re.escape(name)}-[0-9a-f]{{6}}){re.escape(suffix)}")
board_log_pattern = per_board(config_topic, CONF_LOG_TOPIC)
board_logs = board_log_pattern is not None
log_pattern = None if topic_from_cli else board_log_pattern
birth_pattern = per_board(birth_topic, CONF_BIRTH_MESSAGE)
_LOGGER.info(
"Starting log output from %s",
f"{name}-<mac>/debug of every device built from this config"
if log_pattern
else topic,
)
def request_crash_log(client: mqtt.Client, board: str = name) -> None:
logs_topic = f"esphome/logs/{board}"
_LOGGER.debug("Requesting the crash log via topic: %s", logs_topic)
client.publish(logs_topic, None, retain=False)
def shows_board_logs(board: str) -> bool:
board_topic = f"{board}/debug" if board_logs else config_topic
return board_topic is not None and mqtt.topic_matches_sub(topic, board_topic)
def board_to_ask(birth_msg_topic: str) -> str | None:
if birth_pattern is None:
return name if birth_msg_topic == birth_topic and not mac_suffix else None
if (match := birth_pattern.fullmatch(birth_msg_topic)) and (
# Replaying clears the stored crash, so with a --topic only ask the boards it shows
not topic_from_cli or shows_board_logs(match[1])
):
return match[1]
return None
def on_message(client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None:
payload = msg.payload.decode(errors="backslashreplace")
if msg.topic == birth_topic and payload == birth_payload:
request_crash_log(client)
t = msg.topic
# The birth topic may sit inside the log subscription, so print by what was asked for
if mqtt.topic_matches_sub(topic, msg.topic):
time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]")
safe_print(time_ + payload)
shown = (
log_pattern.fullmatch(t)
if log_pattern
else mqtt.topic_matches_sub(topic, t)
)
board = board_to_ask(t)
if not (shown or board):
return
payload = msg.payload.decode(errors="backslashreplace")
if board and payload == birth_payload:
request_crash_log(client, board)
if not shown:
return
if log_pattern:
# Several boards can run this config, so name the one that logged
payload = f"[{shown[1]}]{payload}"
time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]")
safe_print(time_ + payload)
def on_connect(
client: mqtt.Client,
@@ -447,7 +503,9 @@ def show_logs(
if ask_on_connect:
request_crash_log(client)
subscriptions = [topic] if birth_topic is None else [topic, birth_topic]
subscriptions = ["+/debug" if log_pattern else topic]
if birth_topic is not None:
subscriptions.append("+/status" if birth_pattern else birth_topic)
return initialize(
config, subscriptions, on_message, on_connect, username, password, client_id
)
+43
View File
@@ -0,0 +1,43 @@
"""MQTT topic rules shared by code generation and the MQTT log viewer."""
from esphome.const import (
CONF_BIRTH_MESSAGE,
CONF_LOG_TOPIC,
CONF_SHUTDOWN_MESSAGE,
CONF_TOPIC,
CONF_TOPIC_PREFIX,
CONF_WILL_MESSAGE,
)
from esphome.types import ConfigType
# Topic suffix of each message whose topic defaults to one under the topic prefix
DEFAULT_TOPIC_SUFFIXES = {
CONF_BIRTH_MESSAGE: "/status",
CONF_WILL_MESSAGE: "/status",
CONF_SHUTDOWN_MESSAGE: "/status",
CONF_LOG_TOPIC: "/debug",
}
def prefix_gets_mac_suffix(topic_prefix: str, name: str, mac_suffix: bool) -> bool:
"""Whether the firmware appends the MAC suffix to this topic prefix at runtime."""
return mac_suffix and topic_prefix == name
def per_board_suffix(
mqtt_config: ConfigType, key: str, name: str, mac_suffix: bool
) -> str | None:
"""Topic suffix of a message the firmware sends on App.get_name() + suffix, else None.
A message missing from the config counts as the one defaulted from the topic prefix.
"""
topic_prefix = mqtt_config.get(CONF_TOPIC_PREFIX, name)
if not prefix_gets_mac_suffix(topic_prefix, name, mac_suffix):
return None
suffix = DEFAULT_TOPIC_SUFFIXES[key]
if (
key in mqtt_config
and (mqtt_config[key] or {}).get(CONF_TOPIC) != f"{topic_prefix}{suffix}"
):
return None
return suffix
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
max_concurrent_requests: 3
@@ -0,0 +1,20 @@
"""max_concurrent_requests is set only when it differs from the default of 1."""
from collections.abc import Callable
from pathlib import Path
def test_max_concurrent_requests_default_is_not_set(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_esp32_stock.yaml"))
assert "set_max_concurrent_requests" not in main_cpp
def test_max_concurrent_requests_is_set(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("max_concurrent.yaml"))
assert "->set_max_concurrent_requests(3);" in main_cpp
@@ -0,0 +1,15 @@
---
esphome:
name: test-device
name_add_mac_suffix: true
esp8266:
board: d1_mini_lite
wifi:
ssid: MySSID
password: password1
mqtt:
broker: 192.168.1.2
topic_prefix: custom
@@ -0,0 +1,14 @@
---
esphome:
name: test-device
name_add_mac_suffix: true
esp8266:
board: d1_mini_lite
wifi:
ssid: MySSID
password: password1
mqtt:
broker: 192.168.1.2
@@ -0,0 +1,18 @@
---
esphome:
name: test-device
name_add_mac_suffix: true
esp8266:
board: d1_mini_lite
wifi:
ssid: MySSID
password: password1
mqtt:
broker: 192.168.1.2
log_topic: test-device/debug
birth_message:
topic: test-device/status
payload: online
@@ -0,0 +1,18 @@
---
esphome:
name: test-device
name_add_mac_suffix: true
esp8266:
board: d1_mini_lite
wifi:
ssid: MySSID
password: password1
mqtt:
broker: 192.168.1.2
log_topic: test-device/log
birth_message:
topic: test-device
payload: online
@@ -0,0 +1,13 @@
---
esphome:
name: test-device
esp8266:
board: d1_mini_lite
wifi:
ssid: MySSID
password: password1
mqtt:
broker: 192.168.1.2
@@ -0,0 +1,15 @@
---
esphome:
name: test-device
name_add_mac_suffix: true
esp8266:
board: d1_mini_lite
wifi:
ssid: MySSID
password: password1
mqtt:
broker: 192.168.1.2
topic_prefix:
@@ -0,0 +1,82 @@
"""Tests for MQTT topic generation with name_add_mac_suffix."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize(
("config_file", "present", "absent"),
[
pytest.param(
"mac_suffix_default_topics.yaml",
(
"set_topic_prefix(App.get_name())",
'(App.get_name() + "/status")',
'(App.get_name() + "/debug")',
),
('"test-device/status"', '"test-device/debug"'),
id="default topics follow the MAC suffix",
),
pytest.param(
"mac_suffix_explicit_topics.yaml",
(
"set_topic_prefix(App.get_name())",
# will and shutdown are still defaulted from the prefix
'(App.get_name() + "/status")',
'.topic = "test-device/log",',
'.topic = "test-device",',
),
('App.get_name() + "/log"', 'App.get_name() + "/debug"'),
id="explicit topics stay literal",
),
pytest.param(
"mac_suffix_default_written_out.yaml",
('(App.get_name() + "/status")', '(App.get_name() + "/debug")'),
('"test-device/status"', '"test-device/debug"'),
id="topics written out as the default follow the MAC suffix",
),
pytest.param(
"mac_suffix_custom_prefix.yaml",
('set_topic_prefix("custom")', '"custom/status"', '"custom/debug"'),
("App.get_name()",),
id="custom prefix stays literal",
),
pytest.param(
"no_mac_suffix.yaml",
(
'set_topic_prefix("test-device")',
'"test-device/status"',
'"test-device/debug"',
),
("App.get_name()",),
id="no MAC suffix",
),
pytest.param(
"no_topic_prefix.yaml",
(
'set_topic_prefix("")',
"disable_birth_message()",
"disable_last_will()",
"disable_shutdown_message()",
"disable_log_message()",
),
(),
id="null prefix disables default messages",
),
],
)
def test_topics(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
present: tuple[str, ...],
absent: tuple[str, ...],
) -> None:
main_cpp = generate_main(component_config_path(config_file))
for text in present:
assert text in main_cpp
for text in absent:
assert text not in main_cpp
+6
View File
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The driver lives in the sensor platform, so pull in what it builds against.
manifest.dependencies = manifest.dependencies + ["i2c", "sensor"]
+57
View File
@@ -0,0 +1,57 @@
#include <gtest/gtest.h>
#include <array>
#include "esphome/components/as7341/as7341.h"
namespace esphome::as7341::testing {
static constexpr uint8_t ADDRESS = 0x39;
static constexpr uint8_t SMUXEN = 1 << 4;
static constexpr uint8_t AVALID = 1 << 6;
static constexpr uint8_t NUM_ADC_CHANNELS = 6;
// The chip's registers behind an address pointer that moves on after every byte.
// SMUX commands finish at once and spectral data is always valid.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (size != 0) {
this->pointer_ = data[0];
for (size_t i = 1; i < size; i++)
this->registers[this->pointer_++] = data[i];
}
this->registers[AS7341_ENABLE] &= ~SMUXEN;
for (size_t i = 0; i < out_size; i++)
out[i] = this->registers[this->pointer_++];
return i2c::ERROR_OK;
}
std::array<uint8_t, 256> registers{};
protected:
uint8_t pointer_{0};
};
TEST(AS7341Test, ReadChannelsDecodesLowByteFirst) {
RegisterBus bus;
AS7341Component sensor;
sensor.set_i2c_bus(&bus);
sensor.set_i2c_address(ADDRESS);
bus.registers[AS7341_STATUS2] = AVALID;
// CHn_DATA_L holds the low byte and CHn_DATA_H, one address up, the high byte.
for (uint8_t channel = 0; channel < NUM_ADC_CHANNELS; channel++) {
bus.registers[AS7341_CH0_DATA_L + 2 * channel] = 0x34 + channel;
bus.registers[AS7341_CH0_DATA_H + 2 * channel] = 0x12;
}
std::array<uint16_t, 2 * NUM_ADC_CHANNELS> data{};
ASSERT_TRUE(sensor.read_channels(data.data()));
for (size_t i = 0; i < data.size(); i++)
EXPECT_EQ(data[i], 0x1234 + i % NUM_ADC_CHANNELS) << "value " << unsigned(i);
}
} // namespace esphome::as7341::testing
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The send action tests read the response body, which the real to_code enables for
# configs that capture it.
async def to_code_testing(config):
cg.add_define("USE_HTTP_REQUEST_RESPONSE")
manifest.to_code = to_code_testing
-17
View File
@@ -72,23 +72,6 @@ button:
id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json"));
ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url());
- component.update: ota_update
# The OTA component's public setters and getters as lambdas use them; every shape must compile.
- platform: template
name: Flash from lambda
on_press:
then:
- lambda: |-
std::string url = "http://my.ha.net:8123/local/esphome/";
url += "firmware.bin";
id(http_request_ota).set_md5_url("http://my.ha.net:8123/local/esphome/firmware.md5");
id(http_request_ota).set_md5(std::string("0123456789abcdef0123456789abcdef"));
id(http_request_ota).set_username("user");
id(http_request_ota).set_password(std::string("pass"));
id(http_request_ota).set_url(url);
id(http_request_ota).set_url(url.c_str());
ESP_LOGI("test", "MD5 %s / %s", id(http_request_ota).md5_expected().c_str(),
id(http_request_ota).md5_computed().c_str());
id(http_request_ota).flash();
update:
- platform: http_request
@@ -87,6 +87,7 @@ esphome:
http_request:
useragent: esphome/tagreader
timeout: 10s
max_concurrent_requests: 2
verify_ssl: ${verify_ssl}
output:
@@ -0,0 +1,612 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstring>
#include <memory>
#include <string>
#include <vector>
#include "esphome/components/http_request/http_request.h"
#include "esphome/core/base_automation.h"
namespace esphome::http_request::testing {
// A response that stays pending for a number of polls, then serves its body in fixed chunks
class FakeContainer : public HttpContainer {
public:
HttpRequestProgress poll() override {
this->polls++;
if (this->poll_delay_ms > 0)
delay(this->poll_delay_ms);
if (this->pending_polls > 0) {
this->pending_polls--;
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
return this->result;
}
int read(uint8_t *buf, size_t max_len) override { return this->read_available(buf, max_len); }
int read_available(uint8_t *buf, size_t max_len) override {
if (this->stall)
return 0;
if (this->bytes_read_ >= this->fail_at)
return -1;
size_t len = std::min({max_len, this->chunk, this->body.size() - this->bytes_read_});
std::memcpy(buf, this->body.data() + this->bytes_read_, len);
this->bytes_read_ += len;
return static_cast<int>(len);
}
void end() override { this->ends++; }
int pending_polls{0};
// Time each poll() blocks, like the first connect on ESP-IDF
uint32_t poll_delay_ms{0};
HttpRequestProgress result{HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE};
std::string body;
size_t chunk{512};
bool stall{false};
// Reads fail once this many bytes have been read
size_t fail_at{SIZE_MAX};
int polls{0};
int ends{0};
};
class FakeHttpRequest : public HttpRequestComponent {
public:
std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) override {
this->begins++;
if (this->refuse)
return nullptr;
auto container = std::make_shared<FakeContainer>();
container->status_code = 200;
container->pending_polls = this->pending_polls;
container->poll_delay_ms = this->poll_delay_ms;
container->body = this->response_body;
container->content_length = this->response_body.size();
this->containers.push_back(container);
return container;
}
int begins{0};
bool refuse{false};
int pending_polls{2};
uint32_t poll_delay_ms{0};
std::string response_body;
std::vector<std::shared_ptr<FakeContainer>> containers;
protected:
std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) override {
return nullptr;
}
};
// The URL lambda must be stateless, so the stop it makes is set up through this
struct UrlStop {
Automation<> *target{nullptr};
int calls{0};
int stop_at_call{0};
};
UrlStop &url_stop() {
static UrlStop state;
return state;
}
const char *url_that_can_stop() {
UrlStop &state = url_stop();
if (++state.calls == state.stop_at_call)
state.target->stop();
return "http://example.invalid/";
}
class CountAction : public Action<> {
public:
explicit CountAction(int *count) : count_(count) {}
protected:
void play() override { (*this->count_)++; }
int *count_;
};
class HttpRequestSendActionTest : public ::testing::Test {
protected:
void SetUp() override {
this->action_.set_url([]() -> std::string { return "http://example.invalid/"; });
this->action_.set_method([]() -> const char * { return "GET"; });
this->action_.set_max_response_buffer_size(1024);
this->automation_.add_actions({&this->action_, &this->next_});
url_stop() = UrlStop{&this->automation_};
}
// One pass of the main loop
void loop_() {
LoopBlockingGuard guard{nullptr, nullptr, millis()};
this->http_.loop();
}
void run_until_idle_() {
for (int i = 0; i != 100 && this->automation_.is_running(); i++)
this->loop_();
}
void capture_response_() {
this->action_.set_capture_response([]() { return true; });
this->with_body_.add_action(&this->store_body_);
}
FakeHttpRequest http_;
HttpRequestSendAction<> action_{&this->http_};
int next_count_{0};
CountAction next_{&this->next_count_};
Trigger<> trigger_;
Automation<> automation_{&this->trigger_};
int responses_{0};
int errors_{0};
std::string body_;
Automation<std::shared_ptr<HttpContainer>> on_response_{this->action_.get_success_trigger()};
LambdaAction<std::shared_ptr<HttpContainer>> count_response_{
[this](const std::shared_ptr<HttpContainer> &) { this->responses_++; }};
Automation<> on_error_{this->action_.get_error_trigger()};
CountAction count_error_{&this->errors_};
Automation<std::shared_ptr<HttpContainer>, std::string &> with_body_{
this->action_.get_success_trigger_with_response()};
LambdaAction<std::shared_ptr<HttpContainer>, std::string &> store_body_{
[this](const std::shared_ptr<HttpContainer> &, std::string &body) {
this->responses_++;
this->body_ = body;
}};
public:
HttpRequestSendActionTest() {
this->on_response_.add_action(&this->count_response_);
this->on_error_.add_action(&this->count_error_);
}
};
TEST_F(HttpRequestSendActionTest, ChainWaitsForTheResponse) {
this->trigger_.trigger();
EXPECT_EQ(this->http_.begins, 1);
EXPECT_TRUE(this->automation_.is_running());
EXPECT_EQ(this->next_count_, 0);
this->loop_();
this->loop_();
EXPECT_EQ(this->next_count_, 0);
EXPECT_EQ(this->responses_, 0);
this->loop_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->errors_, 0);
EXPECT_EQ(this->next_count_, 1);
EXPECT_EQ(this->http_.containers[0]->ends, 1);
EXPECT_FALSE(this->automation_.is_running());
}
TEST_F(HttpRequestSendActionTest, RequestThatCannotStartFiresOnErrorAtOnce) {
this->http_.refuse = true;
this->trigger_.trigger();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->responses_, 0);
EXPECT_EQ(this->next_count_, 1);
EXPECT_FALSE(this->automation_.is_running());
}
TEST_F(HttpRequestSendActionTest, FailedRequestFiresOnError) {
this->trigger_.trigger();
this->http_.containers[0]->result = HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
this->run_until_idle_();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->responses_, 0);
EXPECT_EQ(this->next_count_, 1);
EXPECT_EQ(this->http_.containers[0]->ends, 1);
}
TEST_F(HttpRequestSendActionTest, SecondTriggerWaitsForTheFirstRequest) {
this->trigger_.trigger();
this->trigger_.trigger();
EXPECT_EQ(this->http_.begins, 1);
this->loop_();
this->loop_();
EXPECT_EQ(this->http_.begins, 1);
// The first finishes, and the second starts on the next pass
this->loop_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->http_.begins, 1);
this->loop_();
EXPECT_EQ(this->http_.begins, 2);
this->run_until_idle_();
EXPECT_EQ(this->responses_, 2);
EXPECT_EQ(this->next_count_, 2);
}
TEST_F(HttpRequestSendActionTest, TriggersBeyondTheQueueAreDropped) {
for (int i = 0; i != 7; i++)
this->trigger_.trigger();
// One running, four waiting, two dropped with on_error
EXPECT_EQ(this->http_.begins, 1);
EXPECT_EQ(this->errors_, 2);
EXPECT_EQ(this->next_count_, 2);
this->run_until_idle_();
EXPECT_EQ(this->http_.begins, 5);
EXPECT_EQ(this->responses_, 5);
EXPECT_EQ(this->next_count_, 7);
}
TEST_F(HttpRequestSendActionTest, WaitingRequestThatCannotStartFiresOnError) {
this->trigger_.trigger();
this->trigger_.trigger();
this->http_.refuse = true;
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->next_count_, 2);
}
TEST_F(HttpRequestSendActionTest, StopEndsRequestsWithoutTriggers) {
this->trigger_.trigger();
this->trigger_.trigger();
this->automation_.stop();
EXPECT_FALSE(this->automation_.is_running());
EXPECT_EQ(this->http_.containers[0]->ends, 1);
this->loop_();
this->loop_();
this->loop_();
EXPECT_EQ(this->http_.begins, 1);
EXPECT_EQ(this->responses_, 0);
EXPECT_EQ(this->errors_, 0);
EXPECT_EQ(this->next_count_, 0);
// The action is usable again after a stop
this->trigger_.trigger();
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->next_count_, 1);
}
TEST_F(HttpRequestSendActionTest, StopFromOnResponseDropsTheRest) {
LambdaAction<std::shared_ptr<HttpContainer>> stop{
[this](const std::shared_ptr<HttpContainer> &) { this->automation_.stop(); }};
this->on_response_.add_action(&stop);
this->trigger_.trigger();
this->trigger_.trigger();
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->http_.begins, 1);
EXPECT_EQ(this->next_count_, 0);
EXPECT_FALSE(this->automation_.is_running());
}
// A script in mode restart that executes itself from on_response
TEST_F(HttpRequestSendActionTest, RestartFromOnResponseContinuesOnlyTheNewRun) {
int restarts = 0;
LambdaAction<std::shared_ptr<HttpContainer>> restart{[this, &restarts](const std::shared_ptr<HttpContainer> &) {
if (restarts++ == 0) {
this->automation_.stop();
this->trigger_.trigger();
}
}};
this->on_response_.add_action(&restart);
this->trigger_.trigger();
this->loop_();
this->loop_();
this->loop_();
EXPECT_EQ(this->http_.begins, 2);
EXPECT_EQ(this->http_.containers[0]->ends, 1);
EXPECT_EQ(this->next_count_, 0);
EXPECT_TRUE(this->automation_.is_running());
this->run_until_idle_();
EXPECT_EQ(this->responses_, 2);
EXPECT_EQ(this->next_count_, 1);
EXPECT_FALSE(this->automation_.is_running());
}
TEST_F(HttpRequestSendActionTest, RestartFromOnErrorContinuesOnlyTheNewRun) {
LambdaAction<> restart{[this]() {
if (this->http_.refuse) {
this->http_.refuse = false;
this->automation_.stop();
this->trigger_.trigger();
}
}};
this->on_error_.add_action(&restart);
this->http_.refuse = true;
this->trigger_.trigger();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->http_.begins, 2);
EXPECT_EQ(this->next_count_, 0);
EXPECT_TRUE(this->automation_.is_running());
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->next_count_, 1);
EXPECT_FALSE(this->automation_.is_running());
}
TEST_F(HttpRequestSendActionTest, TriggerFromOnResponseStartsAtOnce) {
int retriggers = 0;
LambdaAction<std::shared_ptr<HttpContainer>> again{[this, &retriggers](const std::shared_ptr<HttpContainer> &) {
if (retriggers++ == 0)
this->trigger_.trigger();
}};
this->on_response_.add_action(&again);
this->trigger_.trigger();
this->loop_();
this->loop_();
this->loop_();
EXPECT_EQ(this->http_.begins, 2);
this->run_until_idle_();
EXPECT_EQ(this->responses_, 2);
EXPECT_EQ(this->next_count_, 2);
}
TEST_F(HttpRequestSendActionTest, CapturedBodyIsReadAcrossPasses) {
this->capture_response_();
this->http_.pending_polls = 0;
this->http_.response_body = std::string(900, 'a');
this->trigger_.trigger();
this->http_.containers[0]->chunk = 100;
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->body_, std::string(900, 'a'));
EXPECT_EQ(this->next_count_, 1);
}
TEST_F(HttpRequestSendActionTest, CapturedBodyStopsAtTheBufferSize) {
this->capture_response_();
this->action_.set_max_response_buffer_size(300);
this->http_.response_body = std::string(1000, 'b');
this->trigger_.trigger();
this->run_until_idle_();
EXPECT_EQ(this->body_, std::string(300, 'b'));
}
TEST_F(HttpRequestSendActionTest, StalledBodyEndsAtTheTimeout) {
this->capture_response_();
this->action_.set_timeout([]() -> uint32_t { return 20; });
this->http_.response_body = std::string(1000, 'c');
this->trigger_.trigger();
this->http_.containers[0]->stall = true;
this->loop_();
this->loop_();
this->loop_();
EXPECT_TRUE(this->automation_.is_running());
delay(30);
this->loop_();
EXPECT_FALSE(this->automation_.is_running());
EXPECT_EQ(this->responses_, 1);
EXPECT_TRUE(this->body_.empty());
}
// A url lambda that stops the action while the request is being started
TEST_F(HttpRequestSendActionTest, StopFromLambdaWhileStartingEndsTheRequest) {
this->action_.set_url([]() -> std::string { return url_that_can_stop(); });
url_stop().stop_at_call = 1;
this->trigger_.trigger();
EXPECT_FALSE(this->automation_.is_running());
ASSERT_EQ(this->http_.begins, 1);
EXPECT_EQ(this->http_.containers[0]->ends, 1);
this->run_until_idle_();
EXPECT_EQ(this->responses_, 0);
EXPECT_EQ(this->errors_, 0);
EXPECT_EQ(this->next_count_, 0);
this->trigger_.trigger();
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->next_count_, 1);
}
TEST_F(HttpRequestSendActionTest, StopFromLambdaOfAWaitingRequestEndsIt) {
this->action_.set_url([]() -> std::string { return url_that_can_stop(); });
url_stop().stop_at_call = 2;
this->trigger_.trigger();
this->trigger_.trigger();
this->run_until_idle_();
EXPECT_FALSE(this->automation_.is_running());
ASSERT_EQ(this->http_.begins, 2);
EXPECT_EQ(this->http_.containers[1]->polls, 0);
EXPECT_EQ(this->http_.containers[1]->ends, 1);
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->next_count_, 1);
// The slot counts survived the stop, so a new trigger starts at once
this->trigger_.trigger();
EXPECT_EQ(this->http_.begins, 3);
this->run_until_idle_();
EXPECT_EQ(this->responses_, 2);
EXPECT_EQ(this->next_count_, 2);
}
TEST_F(HttpRequestSendActionTest, ReadErrorKeepsTheBodyReadSoFar) {
this->capture_response_();
this->http_.response_body = std::string(800, 'd');
this->trigger_.trigger();
this->http_.containers[0]->chunk = 100;
this->http_.containers[0]->fail_at = 300;
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->errors_, 0);
EXPECT_EQ(this->body_, std::string(300, 'd'));
EXPECT_EQ(this->next_count_, 1);
}
TEST_F(HttpRequestSendActionTest, BufferThatCannotBeAllocatedGivesAnEmptyBody) {
this->capture_response_();
this->action_.set_max_response_buffer_size(SIZE_MAX);
this->http_.response_body = "unread";
this->body_ = "not set";
this->trigger_.trigger();
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_TRUE(this->body_.empty());
EXPECT_EQ(this->next_count_, 1);
}
// A forced reboot calls only on_shutdown(), which finishes the requests in progress
TEST_F(HttpRequestSendActionTest, ShutdownFinishesTheRequestInProgress) {
this->trigger_.trigger();
this->trigger_.trigger();
EXPECT_EQ(this->responses_, 0);
this->http_.on_shutdown();
EXPECT_EQ(this->responses_, 2);
EXPECT_EQ(this->next_count_, 2);
EXPECT_FALSE(this->automation_.is_running());
}
// An on_shutdown automation that runs after the component's on_shutdown()
TEST_F(HttpRequestSendActionTest, RequestAfterShutdownFinishesAtOnce) {
this->http_.on_shutdown();
this->trigger_.trigger();
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->next_count_, 1);
EXPECT_FALSE(this->automation_.is_running());
}
TEST_F(HttpRequestSendActionTest, BodyTimeoutStartsAfterABlockingPoll) {
this->capture_response_();
this->action_.set_timeout([]() -> uint32_t { return 20; });
this->http_.pending_polls = 0;
this->http_.poll_delay_ms = 30;
this->http_.response_body = std::string(100, 'e');
this->trigger_.trigger();
this->http_.containers[0]->stall = true;
this->loop_();
EXPECT_TRUE(this->automation_.is_running());
this->http_.containers[0]->stall = false;
this->run_until_idle_();
EXPECT_EQ(this->body_, std::string(100, 'e'));
}
TEST_F(HttpRequestSendActionTest, DurationCoversTheWholeBody) {
this->capture_response_();
this->http_.pending_polls = 0;
this->http_.response_body = std::string(100, 'f');
this->trigger_.trigger();
auto container = this->http_.containers[0];
container->duration_ms = 1;
container->stall = true;
this->loop_();
delay(30);
container->stall = false;
this->run_until_idle_();
EXPECT_EQ(this->responses_, 1);
EXPECT_GE(container->duration_ms, 30u);
}
// The limit is per http_request component, so that one client is held at a time
TEST_F(HttpRequestSendActionTest, TwoActionsTakeTurns) {
HttpRequestSendAction<> other{&this->http_};
other.set_url([]() -> std::string { return "http://example.invalid/other"; });
other.set_method([]() -> const char * { return "GET"; });
int other_next = 0;
CountAction count_other{&other_next};
Trigger<> other_trigger;
Automation<> other_automation{&other_trigger};
other_automation.add_actions({&other, &count_other});
this->trigger_.trigger();
other_trigger.trigger();
EXPECT_EQ(this->http_.begins, 1);
this->loop_();
this->loop_();
this->loop_();
EXPECT_EQ(this->next_count_, 1);
EXPECT_EQ(other_next, 0);
EXPECT_EQ(this->http_.begins, 1);
for (int i = 0; i != 10 && other_automation.is_running(); i++)
this->loop_();
EXPECT_EQ(this->http_.begins, 2);
EXPECT_EQ(other_next, 1);
}
// Stopping one action leaves the requests of another alone
TEST_F(HttpRequestSendActionTest, StopEndsOnlyThatActionsRequests) {
HttpRequestSendAction<> other{&this->http_};
other.set_url([]() -> std::string { return "http://example.invalid/other"; });
other.set_method([]() -> const char * { return "GET"; });
int other_next = 0;
CountAction count_other{&other_next};
Trigger<> other_trigger;
Automation<> other_automation{&other_trigger};
other_automation.add_actions({&other, &count_other});
other_trigger.trigger();
this->trigger_.trigger();
this->automation_.stop();
for (int i = 0; i != 10 && other_automation.is_running(); i++)
this->loop_();
EXPECT_EQ(other_next, 1);
EXPECT_EQ(this->next_count_, 0);
EXPECT_EQ(this->http_.begins, 1);
}
// A safe shutdown drops the requests already made, so they do not hold up the reboot,
// and still sends the ones that on_shutdown automations make
TEST_F(HttpRequestSendActionTest, SafeShutdownSendsOnlyTheShutdownRequests) {
this->trigger_.trigger();
this->trigger_.trigger();
this->http_.on_safe_shutdown();
EXPECT_EQ(this->http_.containers[0]->ends, 1);
EXPECT_EQ(this->responses_, 0);
EXPECT_EQ(this->errors_, 0);
// Stopped like script.stop, so a mode single script could run it again from on_shutdown
EXPECT_FALSE(this->automation_.is_running());
this->trigger_.trigger();
EXPECT_EQ(this->responses_, 1);
this->http_.on_shutdown();
EXPECT_EQ(this->http_.begins, 2);
EXPECT_EQ(this->responses_, 1);
EXPECT_EQ(this->next_count_, 1);
}
TEST_F(HttpRequestSendActionTest, MaxConcurrentRequestsRunTogether) {
this->http_.set_max_concurrent_requests(2);
this->trigger_.trigger();
this->trigger_.trigger();
this->trigger_.trigger();
EXPECT_EQ(this->http_.begins, 2);
// Both finish on the third pass; the third request takes a free slot on the next
this->loop_();
this->loop_();
this->loop_();
EXPECT_EQ(this->responses_, 2);
EXPECT_EQ(this->http_.begins, 2);
this->loop_();
EXPECT_EQ(this->http_.begins, 3);
this->run_until_idle_();
EXPECT_EQ(this->responses_, 3);
EXPECT_EQ(this->next_count_, 3);
}
TEST_F(HttpRequestSendActionTest, MaxConcurrentRequestsKeepsTheQueueLimit) {
this->http_.set_max_concurrent_requests(2);
for (int i = 0; i != 8; i++)
this->trigger_.trigger();
// Two running, four waiting, two dropped
EXPECT_EQ(this->http_.begins, 2);
EXPECT_EQ(this->errors_, 2);
this->run_until_idle_();
EXPECT_EQ(this->responses_, 6);
EXPECT_EQ(this->next_count_, 8);
}
} // namespace esphome::http_request::testing
@@ -12,8 +12,10 @@
#include "esphome/components/http_request/http_request.h"
#include "esphome/components/online_image/online_image.h"
#include "esphome/components/runtime_image/png_decoder.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "pngle.h"
namespace esphome::online_image::testing {
@@ -135,6 +137,153 @@ 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};
// 800x600 RGB PNG of a single color, 2312 bytes: the decoder needs several calls to draw it.
// The same bytes are in tests/components/runtime_image/test_png_time_limit.cpp.
static const uint8_t PNG_SOLID[] = {
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x03,
0x20, 0x00, 0x00, 0x02, 0x58, 0x08, 0x02, 0x00, 0x00, 0x00, 0x15, 0x14, 0x15, 0x27, 0x00, 0x00, 0x08, 0xCF, 0x49,
0x44, 0x41, 0x54, 0x78, 0xDA, 0xED, 0xD6, 0x31, 0x11, 0x00, 0x30, 0x08, 0x00, 0xB1, 0x52, 0x21, 0xE8, 0x44, 0x22,
0xB2, 0x30, 0xC1, 0x31, 0x25, 0x12, 0x7E, 0xFA, 0xC8, 0xEA, 0x07, 0x00, 0xC0, 0x9E, 0x2F, 0x01, 0x00, 0x80, 0xC1,
0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00,
0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30,
0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58,
0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C,
0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83,
0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05,
0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80,
0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1,
0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00,
0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00,
0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30,
0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83,
0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C,
0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x83, 0x25, 0x01, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80,
0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30,
0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1,
0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00,
0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00,
0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00,
0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00,
0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83,
0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80,
0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30,
0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1,
0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00,
0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00,
0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x25, 0x01, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83,
0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0xDC, 0x19, 0x79, 0xA5, 0x06, 0x0E, 0x33, 0xD4, 0xAF,
0xF2, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
};
// Draws that take a millisecond every fourth row, so decoding PNG_SOLID always outlasts the time limit of one call
class SlowDraws : public runtime_image::PngDecoder, public runtime_image::RuntimeImage {
public:
static void install(runtime_image::RuntimeImage &image) {
auto *decoder = static_cast<runtime_image::PngDecoder *>((image.*&SlowDraws::decoder_).get());
pngle_set_draw_callback(decoder->*&SlowDraws::pngle_, draw);
}
private:
static void draw(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, const uint8_t rgba[4]) {
if (x == 0 && y % 4 == 0) {
const uint32_t until = millis() + 2;
while (millis() < until) {
}
}
static_cast<runtime_image::PngDecoder *>(pngle_get_user_data(pngle))
->draw(x, y, w, h, Color(rgba[0], rgba[1], rgba[2], rgba[3]));
}
};
// 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,
@@ -458,6 +607,30 @@ TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) {
EXPECT_EQ(this->finished_, 0);
}
// The whole PNG arrives in one read, but the decoder stops after a short time per call: the rest is decoded on
// later passes, not reported as data the decoder could not use
TEST_F(OnlineImageDownloadEnd, PngDecodedOverSeveralPassesAfterTheDownloadEnds) {
for (bool chunked : {false, true}) {
this->finished_ = 0;
this->errors_ = 0;
auto image = this->make_image_(runtime_image::PNG, 4096);
auto container = std::make_shared<FakeContainer>(std::vector<uint8_t>(PNG_SOLID, PNG_SOLID + sizeof(PNG_SOLID)),
sizeof(PNG_SOLID), chunked);
this->start_(*image, container);
SlowDraws::install(*image);
int passes = 0;
for (; passes < 1000 && this->finished_ + this->errors_ == 0; passes++)
image->loop();
EXPECT_EQ(this->finished_, 1) << (chunked ? "chunked" : "with Content-Length");
EXPECT_EQ(this->errors_, 0);
EXPECT_GT(passes, 2);
EXPECT_EQ(image->get_width(), 800);
EXPECT_EQ(image->get_pixel(400, 300).b, 200);
}
}
// 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);
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: object) -> None:
cg.add_define("USE_PACKET_INTERFACE_BASIC_SESSION")
cg.add_define("USE_PACKET_INTERFACE_HDLC")
cg.add_define("USE_PACKET_INTERFACE_XXTEA")
manifest.to_code = to_code_testing
@@ -0,0 +1,252 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <memory>
#include <vector>
#include "esphome/components/packet_interface/basic_session.h"
#include "esphome/core/application.h"
namespace esphome::packet_interface::session_testing {
// Copies a packet into a vector, for comparing in tests
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
std::vector<uint8_t> result(data.size());
data.copy_to(result.data(), result.size());
return result;
}
using Packet = std::vector<uint8_t>;
// A link layer that records what is sent, so a test can deliver it to the peer
class MockLink : public PacketInterface {
public:
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
if (!this->accept)
return false;
this->sent.push_back(to_vector(data));
return true;
}
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
// Act as a reliability layer that resets and discards data
void set_reliable() { this->capabilities_ = CAPABILITY_RELIABILITY; }
void report_data_lost() { this->session_callback_.call(false); }
std::vector<Packet> sent;
bool accept{true};
};
class TestSession : public BasicSession {
public:
using BasicSession::BasicSession;
using BasicSession::on_interval_;
unsigned get_not_understood_count() const { return this->not_understood_count_; }
};
struct Peer {
Peer() {
this->session = std::make_unique<TestSession>(&this->link);
this->session->set_keep_alive(1000);
this->session->set_session_timeout(3000);
this->session->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
this->session->add_on_session_state_callback([this](bool connected) { this->states.push_back(connected); });
this->session->setup();
}
bool send(const Packet &data) { return this->session->send_to_interface(PacketBuffer(data), {}); }
MockLink link;
std::unique_ptr<TestSession> session;
std::vector<Packet> received;
std::vector<bool> states;
};
static bool is_keep_alive(const Packet &packet) { return !packet.empty() && packet[0] == 0x01; }
// Move packets between the peers until nothing more is sent
static void pump(Peer &a, Peer &b) {
for (int i = 0; i != 20; i++) {
if (a.link.sent.empty() && b.link.sent.empty())
return;
auto from_a = std::move(a.link.sent);
auto from_b = std::move(b.link.sent);
a.link.sent.clear();
b.link.sent.clear();
for (auto &packet : from_a)
b.link.inject(packet);
for (auto &packet : from_b)
a.link.inject(packet);
}
FAIL() << "Peers did not go quiet";
}
// Each test replaces App with a fresh one; the keep-alive interval needs its scheduler
class BasicSessionTest : public ::testing::Test {
protected:
void SetUp() override {
App.~Application();
new (&App) Application();
static char name[] = "session-tests";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
}
void TearDown() override {
App.~Application();
new (&App) Application(); // later tests and the test listener use App
}
static void set_time(uint32_t now) { LoopBlockingGuard guard{nullptr, nullptr, now}; }
};
TEST_F(BasicSessionTest, MaxPacketSizeAllowsForHeader) {
Peer a;
EXPECT_EQ(a.session->get_max_packet_size(), 64 - BasicSession::HEADER_SIZE);
EXPECT_EQ(a.link.top(), a.session.get());
EXPECT_EQ(a.session->get_capabilities(), CAPABILITY_SESSION);
}
TEST_F(BasicSessionTest, KeepAliveSentAtSetup) {
Peer a;
ASSERT_EQ(a.link.sent.size(), 1);
EXPECT_TRUE(is_keep_alive(a.link.sent[0]));
EXPECT_EQ(a.link.sent[0].size(), 5);
EXPECT_FALSE(a.session->is_connected());
}
TEST_F(BasicSessionTest, BothConnectWithoutRepeatedAnswers) {
Peer a, b;
pump(a, b);
EXPECT_TRUE(a.session->is_connected());
EXPECT_TRUE(b.session->is_connected());
EXPECT_EQ(a.states, std::vector<bool>{true});
EXPECT_EQ(b.states, std::vector<bool>{true});
}
TEST_F(BasicSessionTest, OneSideStartsLater) {
Peer a;
a.link.sent.clear(); // a's first keep-alive is lost
Peer b;
pump(a, b);
EXPECT_TRUE(a.session->is_connected());
EXPECT_TRUE(b.session->is_connected());
}
TEST_F(BasicSessionTest, DataPassesThroughWithHeader) {
Peer a, b;
pump(a, b);
ASSERT_TRUE(a.send({0x10, 0x11}));
ASSERT_EQ(a.link.sent.size(), 1);
EXPECT_EQ(a.link.sent[0], (Packet{0x00, 0x10, 0x11}));
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Packet>{{0x10, 0x11}}));
EXPECT_TRUE(a.received.empty());
}
TEST_F(BasicSessionTest, NotUnderstoodPacketsCountedUntilOneIsUnderstood) {
Peer a;
for (int i = 0; i != 20; i++)
a.link.inject({0x7F, 0x00});
// The count stops where the mismatch is logged, so it is logged once
EXPECT_EQ(a.session->get_not_understood_count(), 10);
a.link.inject({0x00, 0x55});
EXPECT_EQ(a.session->get_not_understood_count(), 0);
}
TEST_F(BasicSessionTest, DataMarksPeerConnected) {
Peer a;
a.link.inject({0x00, 0x42});
EXPECT_TRUE(a.session->is_connected());
EXPECT_EQ(a.received, (std::vector<Packet>{{0x42}}));
}
TEST_F(BasicSessionTest, DataBeforeFirstKeepAliveIsNotARestart) {
Peer a, b;
a.link.sent.clear();
b.link.sent.clear(); // b's first keep-alive is lost
b.send({0x42});
pump(a, b);
b.session->on_interval_();
pump(a, b);
EXPECT_EQ(a.states, std::vector<bool>{true});
}
TEST_F(BasicSessionTest, SendFailureIsReturned) {
Peer a;
a.link.accept = false;
EXPECT_FALSE(a.send({0x01}));
}
TEST_F(BasicSessionTest, IntervalSendsKeepAlive) {
Peer a;
a.link.sent.clear();
a.session->on_interval_();
ASSERT_EQ(a.link.sent.size(), 1);
EXPECT_TRUE(is_keep_alive(a.link.sent[0]));
}
TEST_F(BasicSessionTest, PeerLostAfterTimeout) {
Peer a, b;
set_time(1000);
pump(a, b);
set_time(4000);
a.session->on_interval_();
EXPECT_TRUE(a.session->is_connected());
set_time(4001);
a.session->on_interval_();
EXPECT_FALSE(a.session->is_connected());
EXPECT_EQ(a.states, (std::vector<bool>{true, false}));
// Heard again
b.session->on_interval_();
pump(a, b);
EXPECT_TRUE(a.session->is_connected());
EXPECT_EQ(a.states, (std::vector<bool>{true, false, true}));
}
TEST_F(BasicSessionTest, PeerRestartIsReported) {
Peer a;
auto b = std::make_unique<Peer>();
pump(a, *b);
b = std::make_unique<Peer>();
pump(a, *b);
EXPECT_TRUE(a.session->is_connected());
EXPECT_EQ(a.states, (std::vector<bool>{true, false, true}));
EXPECT_EQ(b->states, std::vector<bool>{true});
}
TEST_F(BasicSessionTest, MalformedPacketsDropped) {
Peer a;
a.link.inject({});
a.link.inject({0x02, 0x00});
a.link.inject({0x01, 0x00, 0x00});
EXPECT_FALSE(a.session->is_connected());
EXPECT_TRUE(a.received.empty());
}
TEST_F(BasicSessionTest, DataLostBelowReportedAsSessionLost) {
MockLink link;
link.set_reliable();
TestSession session(&link);
std::vector<bool> states;
session.add_on_session_state_callback([&states](bool connected) { states.push_back(connected); });
session.setup();
// Not yet connected, so nothing to report
link.report_data_lost();
EXPECT_TRUE(states.empty());
link.inject({0x00, 0x55});
link.report_data_lost();
EXPECT_FALSE(session.is_connected());
// Connected again when the peer is next heard
link.inject({0x00, 0x56});
EXPECT_EQ(states, (std::vector<bool>{true, false, true}));
}
TEST_F(BasicSessionTest, LinkTooSmallForKeepAliveFailsSetup) {
MockLink link(4);
TestSession session(&link);
session.setup();
EXPECT_TRUE(session.is_failed());
EXPECT_TRUE(link.sent.empty());
Packet packet{1};
EXPECT_FALSE(session.send_to_interface(PacketBuffer(packet), {}));
}
} // namespace esphome::packet_interface::session_testing
@@ -1,7 +1,33 @@
wifi:
ssid: MySSID
password: password1
udp:
- id: packet_interface_udp
packet_interface:
- platform: uart
id: packet_interface_uart
max_packet_size: 256
reliability:
timeout: 300ms
ack_delay: 50ms
max_queue_length: 3
max_retries: 4
encryption:
key: Johnny Cash
rolling_code_enable: true
session:
keep_alive: 5s
timeout: 12s
binary_sensor:
name: UART session
- platform: udp
id: packet_interface_network
udp_id: packet_interface_udp
encryption: Johnny Cash
reliability:
session:
sensor:
- platform: template
@@ -13,3 +39,7 @@ packet_transport:
packet_interface: packet_interface_uart
sensors:
- packet_interface_sensor
- platform: packet_interface
packet_interface: packet_interface_network
sensors:
- packet_interface_sensor
@@ -0,0 +1,464 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <functional>
#include <memory>
#include <vector>
#include "esphome/components/packet_interface/hdlc_reliability.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
namespace esphome::packet_interface::testing {
// Copies a packet into a vector, for comparing in tests
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
std::vector<uint8_t> result(data.size());
data.copy_to(result.data(), result.size());
return result;
}
using Frame = std::vector<uint8_t>;
// A link layer that records what is sent, so a test can deliver it to the peer
class MockLink : public PacketInterface {
public:
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
if (this->budget == 0) {
this->refused++;
return false;
}
this->budget--;
this->sent.push_back(to_vector(data));
return true;
}
void inject(const Frame &frame) { this->on_receive_from_interface_(PacketBuffer(frame), {}); }
std::vector<Frame> sent;
size_t budget{SIZE_MAX}; // frames accepted before the link refuses, like a full network stack
size_t refused{0};
};
class TestHdlc : public HdlcReliability {
public:
using HdlcReliability::HdlcReliability;
using HdlcReliability::on_timeout_;
using HdlcReliability::outstanding_;
};
struct Peer {
explicit Peer(uint8_t queue_length = 4) {
this->hdlc = std::make_unique<TestHdlc>(&this->link);
this->hdlc->set_max_queue_length(queue_length);
this->hdlc->set_max_retries(3);
this->hdlc->setup();
this->hdlc->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
this->hdlc->add_on_session_state_callback([this](bool connected) { this->states.push_back(connected); });
}
bool send(const Frame &data) { return this->hdlc->send_to_interface(PacketBuffer(data), {}); }
MockLink link;
std::unique_ptr<TestHdlc> hdlc;
std::vector<Frame> received;
std::vector<bool> states;
};
// Returns true to drop a frame on its way from one peer to the other
using DropFilter = std::function<bool(const Frame &)>;
static bool is_info(const Frame &frame) { return (frame[0] & 1) == 0; }
static bool is_ack(const Frame &frame) { return (frame[0] & 0x0F) == 0x01; }
// Runs a layer's loop only while it has asked for it, as the main loop does
static void run_loop(TestHdlc &hdlc) {
if (!hdlc.is_idle())
hdlc.loop();
}
// Move frames between the peers and run their loops until nothing more is sent
static void pump(Peer &a, Peer &b, const DropFilter &drop_a_to_b = nullptr, const DropFilter &drop_b_to_a = nullptr) {
for (int i = 0; i != 50; i++) {
run_loop(*a.hdlc);
run_loop(*b.hdlc);
if (a.link.sent.empty() && b.link.sent.empty())
return;
auto from_a = std::move(a.link.sent);
auto from_b = std::move(b.link.sent);
a.link.sent.clear();
b.link.sent.clear();
for (auto &frame : from_a) {
if (!drop_a_to_b || !drop_a_to_b(frame))
b.link.inject(frame);
}
for (auto &frame : from_b) {
if (!drop_b_to_a || !drop_b_to_a(frame))
a.link.inject(frame);
}
}
FAIL() << "Peers did not go quiet";
}
static Frame make_frame(uint8_t control, const Frame &payload) {
Frame frame{control};
frame.insert(frame.end(), payload.begin(), payload.end());
uint16_t crc = crc16(frame.data(), frame.size());
frame.push_back(crc & 0xFF);
frame.push_back(crc >> 8);
return frame;
}
// Each test replaces App with a fresh one; the retransmit timer needs its scheduler
class HdlcReliabilityTest : public ::testing::Test {
protected:
void SetUp() override {
App.~Application();
new (&App) Application();
static char name[] = "hdlc-tests";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
}
void TearDown() override {
App.~Application();
new (&App) Application(); // later tests and the test listener use App
}
};
TEST_F(HdlcReliabilityTest, MaxPacketSizeAllowsForOverhead) {
Peer a;
EXPECT_EQ(a.hdlc->get_send_capacity(), 4);
EXPECT_EQ(a.hdlc->get_max_packet_size(), 64 - HdlcReliability::FRAME_OVERHEAD);
EXPECT_EQ(a.link.top(), a.hdlc.get());
EXPECT_EQ(a.hdlc->get_capabilities(), CAPABILITY_RELIABILITY);
}
TEST_F(HdlcReliabilityTest, ResetHandshake) {
Peer a, b;
a.hdlc->loop();
ASSERT_EQ(a.link.sent.size(), 1);
EXPECT_EQ(a.link.sent[0], make_frame(0x2F, {})); // SABM
pump(a, b);
EXPECT_TRUE(a.hdlc->is_connected());
EXPECT_TRUE(b.hdlc->is_connected());
}
TEST_F(HdlcReliabilityTest, RepeatedSabmBeforeNumberedFramesKeepsQueue) {
Peer a, b;
pump(a, b);
// A frame is queued and sent, but has not reached A, so A has sent no numbered frame
ASSERT_TRUE(b.send({0x42}));
auto held = std::move(b.link.sent);
b.link.sent.clear();
EXPECT_EQ(b.hdlc->get_send_capacity(), 3);
// A repeats its SABM because it did not hear the UA; that is not a restart
b.link.inject(make_frame(0x2F, {})); // SABM
EXPECT_TRUE(b.hdlc->is_connected());
EXPECT_TRUE(b.states.empty()); // no session lost reported
EXPECT_EQ(b.hdlc->get_send_capacity(), 3); // the queued frame was kept
ASSERT_FALSE(b.link.sent.empty());
EXPECT_EQ(b.link.sent[0], make_frame(0x63, {})); // UA, then the frame is sent again
// The frame is still delivered when it reaches A
for (auto &frame : held)
a.link.inject(frame);
EXPECT_EQ(a.received, std::vector<Frame>{{0x42}});
}
TEST_F(HdlcReliabilityTest, SabmAfterNumberedFramesIsARestart) {
Peer a, b;
pump(a, b);
// B has heard a numbered frame, so a SABM now means A has started again
ASSERT_TRUE(a.send({0x10}));
pump(a, b);
b.link.inject(make_frame(0x2F, {})); // SABM
EXPECT_TRUE(b.hdlc->is_connected());
EXPECT_EQ(b.states, std::vector<bool>{false});
}
TEST_F(HdlcReliabilityTest, QueuedBeforeConnectIsSentAfter) {
Peer a, b;
ASSERT_TRUE(a.send({0x01}));
ASSERT_TRUE(a.send({0x02}));
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{0x01}, {0x02}}));
}
TEST_F(HdlcReliabilityTest, DeliversInOrderBothWays) {
Peer a, b;
pump(a, b);
a.send({0x10});
b.send({0x20});
a.send({0x11});
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{0x10}, {0x11}}));
EXPECT_EQ(a.received, (std::vector<Frame>{{0x20}}));
}
TEST_F(HdlcReliabilityTest, SequenceNumbersWrap) {
Peer a(3), b(3);
pump(a, b);
std::vector<Frame> expected;
for (uint8_t i = 0; i != 20; i++) {
expected.push_back({i, static_cast<uint8_t>(i * 3)});
ASSERT_TRUE(a.send(expected.back()));
if (i % 2 == 1)
pump(a, b);
}
pump(a, b);
EXPECT_EQ(b.received, expected);
}
TEST_F(HdlcReliabilityTest, GapRecoveredByReject) {
Peer a, b;
pump(a, b);
bool dropped = false;
a.send({0x01});
a.send({0x02});
a.send({0x03});
pump(a, b, [&dropped](const Frame &frame) {
if (dropped || !is_info(frame))
return false;
dropped = true;
return true;
});
EXPECT_EQ(b.received, (std::vector<Frame>{{0x01}, {0x02}, {0x03}}));
}
TEST_F(HdlcReliabilityTest, LossRecoveredByTimeout) {
Peer a, b;
pump(a, b);
a.send({0x42});
pump(a, b, [](const Frame &frame) { return is_info(frame); });
EXPECT_TRUE(b.received.empty());
a.hdlc->on_timeout_();
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{0x42}}));
}
TEST_F(HdlcReliabilityTest, LostAckDoesNotDuplicate) {
Peer a, b;
pump(a, b);
a.send({0x42});
pump(a, b, nullptr, [](const Frame &frame) { return is_ack(frame); });
ASSERT_EQ(b.received.size(), 1);
a.hdlc->on_timeout_();
pump(a, b);
EXPECT_EQ(b.received.size(), 1);
// The resend was acknowledged, so the full queue is available again
for (int i = 0; i != 4; i++)
EXPECT_TRUE(a.send({0x01}));
}
TEST_F(HdlcReliabilityTest, QueueFull) {
Peer a(2), b(2);
pump(a, b);
EXPECT_TRUE(a.send({0x01}));
EXPECT_TRUE(a.send({0x02}));
EXPECT_FALSE(a.send({0x03}));
}
TEST_F(HdlcReliabilityTest, OversizedPacketRejected) {
Peer a;
EXPECT_FALSE(a.send(Frame(a.hdlc->get_max_packet_size() + 1)));
}
TEST_F(HdlcReliabilityTest, CrcErrorDropped) {
Peer a, b;
pump(a, b);
Frame frame = make_frame(0x00, {0x55}); // I-frame N(S)=0 N(R)=0
frame[1] ^= 0xFF;
b.link.inject(frame);
EXPECT_TRUE(b.received.empty());
b.link.inject(make_frame(0x00, {0x55}));
EXPECT_EQ(b.received, (std::vector<Frame>{{0x55}}));
}
TEST_F(HdlcReliabilityTest, RetriesExhaustedResetsLink) {
Peer a, b;
pump(a, b);
// One frame is delivered first, so b can tell a reset from a repeated SABM
ASSERT_TRUE(a.send({0x00}));
pump(a, b);
ASSERT_EQ(b.received, std::vector<Frame>{{0x00}});
a.send({0x01});
pump(a, b, [](const Frame &frame) { return is_info(frame); });
for (int i = 0; i != 4; i++) {
a.hdlc->on_timeout_();
pump(a, b, [](const Frame &frame) { return is_info(frame); });
}
// The unacknowledged frame was discarded and the link was reset, which both report as data lost
EXPECT_EQ(b.received, std::vector<Frame>{{0x00}});
EXPECT_TRUE(a.hdlc->is_connected());
EXPECT_EQ(a.states, std::vector<bool>{false});
EXPECT_EQ(b.states, std::vector<bool>{false});
a.send({0x02});
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{0x00}, {0x02}}));
}
TEST_F(HdlcReliabilityTest, PeerRestart) {
Peer a;
auto b = std::make_unique<Peer>();
pump(a, *b);
a.send({0x01});
pump(a, *b);
// The peer restarts with fresh sequence numbers
b = std::make_unique<Peer>();
pump(a, *b);
a.send({0x02});
b->send({0x03});
pump(a, *b);
EXPECT_EQ(b->received, (std::vector<Frame>{{0x02}}));
EXPECT_EQ(a.received, (std::vector<Frame>{{0x03}}));
// The restarted peer's reset discarded what a had not yet had acknowledged
EXPECT_EQ(a.states, std::vector<bool>{false});
EXPECT_TRUE(b->states.empty());
}
// Waits past the retry after a refused send, servicing timers as the main loop does
static void run_send_retry() {
const uint32_t start = millis();
while (millis() - start < 60) {
App.scheduler.call(millis());
delay(1);
}
App.scheduler.call(millis());
}
TEST_F(HdlcReliabilityTest, RefusedFramesSentAgainWithoutTimeout) {
Peer a, b;
// Only the retry of a refused send can fire while the test runs, not a retransmit
a.hdlc->set_ack_timeout(10000);
b.hdlc->set_ack_timeout(10000);
for (uint8_t i = 0; i != 3; i++)
ASSERT_TRUE(a.send({i}));
a.link.budget = 3; // SABM, UA to the peer's SABM, and the first queued frame
pump(a, b);
EXPECT_GE(a.link.refused, 1);
EXPECT_EQ(b.received, (std::vector<Frame>{{0}}));
a.link.budget = SIZE_MAX; // the lower layer accepts again, as when a radio stops receiving
// The layer waits for its retry rather than running every loop
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{0}}));
run_send_retry();
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{0}, {1}, {2}}));
EXPECT_EQ(a.hdlc->outstanding_(), 0);
}
TEST_F(HdlcReliabilityTest, RefusedAckSentAgain) {
Peer a, b;
a.hdlc->set_ack_timeout(10000);
b.hdlc->set_ack_timeout(10000);
pump(a, b);
a.send({1});
for (auto &frame : a.link.sent)
b.link.inject(frame);
a.link.sent.clear();
b.link.budget = 0;
b.hdlc->loop();
EXPECT_TRUE(b.link.sent.empty());
b.link.budget = SIZE_MAX;
pump(a, b);
EXPECT_EQ(a.hdlc->outstanding_(), 1); // the RR waits for the retry
run_send_retry();
pump(a, b);
EXPECT_EQ(a.hdlc->outstanding_(), 0);
}
TEST_F(HdlcReliabilityTest, AckDelayLetsReplyCarryAck) {
Peer a, b;
b.hdlc->set_ack_delay(100);
pump(a, b);
a.send({1});
for (auto &frame : a.link.sent)
b.link.inject(frame);
a.link.sent.clear();
b.hdlc->loop();
EXPECT_TRUE(b.link.sent.empty()); // no RR yet
b.send({2});
ASSERT_EQ(b.link.sent.size(), 1);
EXPECT_TRUE(is_info(b.link.sent[0]));
EXPECT_EQ(b.link.sent[0][0] >> 5, 1); // N(R) acknowledges the frame from a
b.link.sent.clear();
b.hdlc->loop();
EXPECT_TRUE(b.link.sent.empty()); // the RR is no longer owed
}
TEST_F(HdlcReliabilityTest, MaxRetriesOf255ResetsLink) {
Peer a, b;
a.hdlc->set_max_retries(255);
pump(a, b);
a.send({0x01});
a.link.sent.clear();
for (int i = 0; i != 255; i++) {
a.hdlc->on_timeout_();
a.link.sent.clear();
}
EXPECT_TRUE(a.states.empty());
a.hdlc->on_timeout_();
EXPECT_EQ(a.states, std::vector<bool>{false});
}
TEST_F(HdlcReliabilityTest, OversizedFrameDropped) {
Peer a, b;
pump(a, b);
// An I-frame, N(S) 0 and N(R) 0, one byte larger than this layer's packets
b.link.inject(make_frame(0x00, Frame(b.hdlc->get_max_packet_size() + 1, 0x55)));
EXPECT_TRUE(b.received.empty());
b.link.inject(make_frame(0x00, Frame(b.hdlc->get_max_packet_size(), 0x55)));
EXPECT_EQ(b.received.size(), 1);
}
TEST_F(HdlcReliabilityTest, LinkTooSmallFailsSetup) {
MockLink link(HdlcReliability::FRAME_OVERHEAD);
TestHdlc hdlc(&link);
EXPECT_EQ(hdlc.get_max_packet_size(), 0);
hdlc.setup();
EXPECT_TRUE(hdlc.is_failed());
Frame packet{1};
EXPECT_FALSE(hdlc.send_to_interface(PacketBuffer(packet), {}));
EXPECT_EQ(hdlc.get_send_capacity(), 0);
}
TEST_F(HdlcReliabilityTest, AckDelayTimerSendsAck) {
Peer a, b;
b.hdlc->set_ack_delay(5);
pump(a, b);
a.send({1});
for (auto &frame : a.link.sent)
b.link.inject(frame);
a.link.sent.clear();
b.hdlc->loop();
EXPECT_TRUE(b.link.sent.empty());
delay(10);
App.scheduler.call(millis());
b.hdlc->loop();
ASSERT_EQ(b.link.sent.size(), 1);
EXPECT_TRUE(is_ack(b.link.sent[0]));
EXPECT_EQ(b.link.sent[0][0] >> 5, 1); // N(R) acknowledges the frame from a
}
TEST_F(HdlcReliabilityTest, RefusedRejectFallsBackToAck) {
Peer a, b;
pump(a, b);
a.send({1});
a.send({2});
ASSERT_EQ(a.link.sent.size(), 2);
// The first frame is lost, and the REJ for the gap is refused
b.link.budget = 0;
b.link.inject(a.link.sent[1]);
a.link.sent.clear();
EXPECT_EQ(b.link.refused, 1);
b.link.budget = SIZE_MAX;
b.hdlc->loop();
ASSERT_EQ(b.link.sent.size(), 1);
EXPECT_TRUE(is_ack(b.link.sent[0]));
EXPECT_EQ(b.link.sent[0][0] >> 5, 0);
a.hdlc->on_timeout_();
pump(a, b);
EXPECT_EQ(b.received, (std::vector<Frame>{{1}, {2}}));
}
} // namespace esphome::packet_interface::testing
@@ -0,0 +1,282 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <memory>
#include <vector>
#include "esphome/components/packet_interface/xxtea_encryption.h"
#include "esphome/core/application.h"
namespace esphome::packet_interface::encryption_testing {
// Copies a packet into a vector, for comparing in tests
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
std::vector<uint8_t> result(data.size());
data.copy_to(result.data(), result.size());
return result;
}
using Packet = std::vector<uint8_t>;
// A link layer that records what is sent, so a test can deliver it to the peer
class MockLink : public PacketInterface {
public:
explicit MockLink(size_t max_packet_size = 64) : PacketInterface(max_packet_size) {}
bool send_to_interface(const PacketBuffer &data, const PacketMetaData &) override {
this->sent.push_back(to_vector(data));
return true;
}
void inject(const Packet &packet) { this->on_receive_from_interface_(PacketBuffer(packet), {}); }
std::vector<Packet> sent;
};
static constexpr size_t ROLLING_TRAILER_SIZE = 16;
static constexpr size_t TRAILER_SIZE = 4;
class TestEncryption : public XxteaEncryption {
public:
using XxteaEncryption::XxteaEncryption;
uint32_t get_boot() const { return this->boot_; }
void set_counter(uint32_t counter) { this->counter_ = counter; }
};
struct Peer {
explicit Peer(uint32_t key_word = 0x12345678, size_t max_packet_size = 64, bool rolling_code = true)
: link(max_packet_size) {
this->link.set_name("test_link");
this->encryption = std::make_unique<TestEncryption>(&this->link, rolling_code);
this->encryption->set_key({key_word, 2, 3, 4, 5, 6, 7, 8});
this->encryption->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
this->encryption->setup();
}
bool send(const Packet &data) { return this->encryption->send_to_interface(PacketBuffer(data), {}); }
// Deliver everything sent so far to the peer
void deliver_to(Peer &peer) {
for (auto &packet : this->link.sent)
peer.link.inject(packet);
this->link.sent.clear();
}
// Exchange a packet each way so that each peer echoes the other's boot number
void introduce(Peer &peer) {
this->send({});
this->deliver_to(peer);
peer.send({});
peer.deliver_to(*this);
this->send({});
this->deliver_to(peer);
this->received.clear();
peer.received.clear();
}
MockLink link;
std::unique_ptr<TestEncryption> encryption;
std::vector<Packet> received;
};
// Each test replaces App with a fresh one
class XxteaEncryptionTest : public ::testing::Test {
protected:
void SetUp() override {
App.~Application();
new (&App) Application();
static char name[] = "xxtea-tests";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
}
void TearDown() override {
App.~Application();
new (&App) Application(); // later tests and the test listener use App
}
};
TEST_F(XxteaEncryptionTest, MaxPacketSizeAllowsForTrailerAndWords) {
Peer a;
EXPECT_EQ(a.encryption->get_max_packet_size(), 64 - ROLLING_TRAILER_SIZE);
EXPECT_EQ(a.link.top(), a.encryption.get());
EXPECT_TRUE(a.encryption->has_capability(CAPABILITY_ENCRYPTION));
EXPECT_FALSE(a.encryption->has_capability(CAPABILITY_SESSION));
Peer b(0x12345678, 63);
EXPECT_EQ(b.encryption->get_max_packet_size(), 60 - ROLLING_TRAILER_SIZE);
Peer c(0x12345678, 64, false);
EXPECT_EQ(c.encryption->get_max_packet_size(), 64 - TRAILER_SIZE);
}
static void round_trip_all_sizes(bool rolling_code, size_t trailer_size) {
Peer a(0x12345678, 64, rolling_code), b(0x12345678, 64, rolling_code);
a.introduce(b);
std::vector<Packet> expected;
for (size_t size = 0; size <= a.encryption->get_max_packet_size(); size++) {
Packet packet(size);
for (size_t i = 0; i != size; i++)
packet[i] = static_cast<uint8_t>(size + i);
expected.push_back(packet);
ASSERT_TRUE(a.send(packet));
ASSERT_EQ(a.link.sent.back().size() % 4, 0);
ASSERT_GE(a.link.sent.back().size(), std::max<size_t>(8, size + trailer_size));
}
a.deliver_to(b);
EXPECT_EQ(b.received, expected);
}
TEST_F(XxteaEncryptionTest, RoundTripAllSizes) { round_trip_all_sizes(true, ROLLING_TRAILER_SIZE); }
TEST_F(XxteaEncryptionTest, RoundTripAllSizesWithoutRollingCode) { round_trip_all_sizes(false, TRAILER_SIZE); }
TEST_F(XxteaEncryptionTest, WithoutRollingCodeReplayAccepted) {
Peer a(0x12345678, 64, false), b(0x12345678, 64, false);
a.send({1});
b.link.inject(a.link.sent[0]);
b.link.inject(a.link.sent[0]);
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {1}}));
}
TEST_F(XxteaEncryptionTest, RollingCodeSettingMustMatch) {
Peer a(0x12345678, 64, true), b(0x12345678, 64, false);
a.send({1, 2, 3, 4, 5, 6, 7, 8});
b.send({1, 2, 3, 4, 5, 6, 7, 8});
a.deliver_to(b);
b.deliver_to(a);
EXPECT_TRUE(a.received.empty());
EXPECT_TRUE(b.received.empty());
}
TEST_F(XxteaEncryptionTest, SamePacketEncryptsDifferently) {
Peer a;
a.send({1, 2, 3, 4});
a.send({1, 2, 3, 4});
ASSERT_EQ(a.link.sent.size(), 2);
EXPECT_NE(a.link.sent[0], a.link.sent[1]);
}
TEST_F(XxteaEncryptionTest, OversizedPacketRefused) {
Peer a;
EXPECT_FALSE(a.send(Packet(a.encryption->get_max_packet_size() + 1)));
EXPECT_TRUE(a.link.sent.empty());
}
TEST_F(XxteaEncryptionTest, WrongKeyDropped) {
Peer a, b(0x87654321);
a.send({1, 2, 3});
a.deliver_to(b);
EXPECT_TRUE(b.received.empty());
}
TEST_F(XxteaEncryptionTest, ChangedPacketDropped) {
Peer a, b;
a.send({1, 2, 3});
a.link.sent[0][2] ^= 0x01;
a.deliver_to(b);
EXPECT_TRUE(b.received.empty());
}
TEST_F(XxteaEncryptionTest, BadLengthDropped) {
Peer a, b;
a.send({1, 2, 3});
Packet packet = a.link.sent[0];
b.link.inject(Packet(packet.begin(), packet.end() - 1));
b.link.inject(Packet(8));
EXPECT_TRUE(b.received.empty());
}
TEST_F(XxteaEncryptionTest, ReplayDropped) {
Peer a, b;
a.introduce(b);
a.send({1});
Packet first = a.link.sent[0];
a.send({2});
a.deliver_to(b);
b.link.inject(first);
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {2}}));
}
TEST_F(XxteaEncryptionTest, OlderPacketDropped) {
Peer a, b;
a.introduce(b);
a.send({1});
a.send({2});
b.link.inject(a.link.sent[1]);
b.link.inject(a.link.sent[0]);
EXPECT_EQ(b.received, (std::vector<Packet>{{2}}));
}
TEST_F(XxteaEncryptionTest, BootNumberIncreasesAndPeerRestartAccepted) {
Peer b;
auto a = std::make_unique<Peer>();
uint32_t boot = a->encryption->get_boot();
a->introduce(b);
a->send({1});
a->send({2});
a->deliver_to(b);
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {2}}));
a = std::make_unique<Peer>(); // the peer restarts, and its counter starts again from 0
EXPECT_EQ(a->encryption->get_boot(), boot + 1);
a->introduce(b);
a->send({3});
a->deliver_to(b);
EXPECT_EQ(b.received, std::vector<Packet>{{3}});
}
TEST_F(XxteaEncryptionTest, FirstPacketsDroppedUntilEchoed) {
Peer a, b;
a.send({1});
a.deliver_to(b);
EXPECT_TRUE(b.received.empty()); // a does not know b's boot number yet
b.send({2});
b.deliver_to(a);
EXPECT_EQ(a.received, std::vector<Packet>{{2}}); // b learnt a's from the dropped packet
a.send({3});
a.deliver_to(b);
EXPECT_EQ(b.received, std::vector<Packet>{{3}});
}
TEST_F(XxteaEncryptionTest, PacketsFromBeforeRestartDropped) {
Peer a;
auto b = std::make_unique<Peer>();
a.introduce(*b);
a.send({1});
a.send({2});
std::vector<Packet> recorded = a.link.sent;
a.link.sent.clear();
b = std::make_unique<Peer>(); // b restarts and forgets a's counter
for (auto &packet : recorded)
b->link.inject(packet);
EXPECT_TRUE(b->received.empty());
a.introduce(*b);
a.send({3});
a.deliver_to(*b);
EXPECT_EQ(b->received, std::vector<Packet>{{3}});
}
TEST_F(XxteaEncryptionTest, LinkTooSmallFailsSetup) {
MockLink link(ROLLING_TRAILER_SIZE + 3);
TestEncryption encryption(&link, true);
EXPECT_EQ(encryption.get_max_packet_size(), 0);
encryption.setup();
EXPECT_TRUE(encryption.is_failed());
Packet packet{1};
EXPECT_FALSE(encryption.send_to_interface(PacketBuffer(packet), {}));
}
TEST_F(XxteaEncryptionTest, SendWithoutBuffersRefused) {
MockLink link(64);
TestEncryption encryption(&link, false);
Packet packet{1};
EXPECT_FALSE(encryption.send_to_interface(PacketBuffer(packet), {}));
EXPECT_TRUE(link.sent.empty());
}
TEST_F(XxteaEncryptionTest, CounterWrapStartsNewBoot) {
Peer a, b(0x12345678);
a.introduce(b);
uint32_t boot = a.encryption->get_boot();
a.encryption->set_counter(UINT32_MAX);
ASSERT_TRUE(a.send({1}));
EXPECT_EQ(a.encryption->get_boot(), boot + 1);
ASSERT_TRUE(a.send({2}));
a.deliver_to(b);
// Both were accepted: the last packet of the old boot, then the first of the new one
EXPECT_EQ(b.received, (std::vector<Packet>{{1}, {2}}));
}
} // namespace esphome::packet_interface::encryption_testing
@@ -3,12 +3,14 @@ wifi:
password: password1
udp:
listen_address: 239.0.60.53
addresses: ["239.0.60.53"]
- id: transport_udp_component
listen_address: 239.0.60.53
addresses: ["239.0.60.53"]
packet_transport:
- platform: udp
id: transport_udp
udp_id: transport_udp_component
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
@@ -0,0 +1,151 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <utility>
#include <vector>
#include "esphome/components/runtime_image/qoi_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
// Big enough that decoding it takes well over DECODE_TIME_LIMIT_MS, little enough to build quickly
static constexpr uint32_t WIDTH = 4096;
static constexpr uint32_t HEIGHT = 2048;
static void put_le32(std::vector<uint8_t> &data, size_t offset, uint32_t value) {
for (size_t i = 0; i < 4; i++)
data[offset + i] = (value >> (8 * i)) & 0xFF;
}
static void put_be32(std::vector<uint8_t> &data, size_t offset, uint32_t value) {
for (size_t i = 0; i < 4; i++)
data[offset + i] = (value >> (24 - 8 * i)) & 0xFF;
}
// A 1-bit BMP with the left half of every row set: eight pixels for each byte read
static std::vector<uint8_t> make_mono_bmp() {
constexpr size_t data_offset = 14 + 40 + 2 * 4;
constexpr size_t row_bytes = WIDTH / 8;
std::vector<uint8_t> bmp(data_offset + row_bytes * HEIGHT, 0);
bmp[0] = 'B';
bmp[1] = 'M';
put_le32(bmp, 2, bmp.size());
put_le32(bmp, 10, data_offset);
put_le32(bmp, 14, 40); // DIB header size
put_le32(bmp, 18, WIDTH);
put_le32(bmp, 22, HEIGHT);
bmp[26] = 1; // color planes
bmp[28] = 1; // bits per pixel
for (size_t row = 0; row < HEIGHT; row++) {
for (size_t i = 0; i < row_bytes / 2; i++)
bmp[data_offset + row * row_bytes + i] = 0xFF;
}
return bmp;
}
// A QOI image of one color: an RGB chunk, then runs of 62 pixels, each run a single byte
static std::vector<uint8_t> make_solid_qoi() {
std::vector<uint8_t> qoi(14, 0);
qoi[0] = 'q';
qoi[1] = 'o';
qoi[2] = 'i';
qoi[3] = 'f';
put_be32(qoi, 4, WIDTH);
put_be32(qoi, 8, HEIGHT);
qoi[12] = 3; // channels
qoi.insert(qoi.end(), {0xFE, 30, 120, 200});
for (size_t left = WIDTH * HEIGHT - 1; left > 0;) {
const size_t run = std::min<size_t>(left, 62);
qoi.push_back(0xC0 | (run - 1));
left -= run;
}
qoi.insert(qoi.end(), {0, 0, 0, 0, 0, 0, 0, 1});
return qoi;
}
// Feed the whole image as often as it takes, and return how many calls that was
static int decode_in_calls(RuntimeImage &img, std::vector<uint8_t> &data) {
EXPECT_TRUE(img.begin_decode(data.size()));
size_t offset = 0;
int calls = 0;
while (offset < data.size() && calls < 10000) {
const int consumed = img.feed_data(data.data() + offset, data.size() - offset);
EXPECT_GT(consumed, 0);
if (consumed <= 0)
break;
offset += consumed;
calls++;
}
EXPECT_EQ(offset, data.size());
EXPECT_TRUE(img.end_decode());
return calls;
}
TEST(DecodeTimeLimit, BmpIsDecodedOverSeveralCalls) {
RuntimeImage img(BMP, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE);
auto data = make_mono_bmp();
EXPECT_GT(decode_in_calls(img, data), 1) << "the whole image was decoded in one call";
ASSERT_EQ(img.get_width(), static_cast<int>(WIDTH));
ASSERT_EQ(img.get_height(), static_cast<int>(HEIGHT));
for (auto [col, row] : {std::pair{0, 0}, std::pair{1000, 1000}, std::pair{4095, 2047}}) {
const uint8_t expected = col < static_cast<int>(WIDTH / 2) ? 255 : 0;
EXPECT_EQ(img.get_pixel(col, row).r, expected) << col << "," << row;
}
}
// Bytes after the pixel data are used up, not drawn; here they would be colour indexes past the end of the table
TEST(DecodeTimeLimit, BmpIgnoresBytesAfterThePixels) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
constexpr size_t data_offset = 14 + 40 + 2 * 4;
std::vector<uint8_t> data(data_offset, 0);
data[0] = 'B';
data[1] = 'M';
put_le32(data, 10, data_offset);
put_le32(data, 14, 40); // DIB header size
put_le32(data, 18, 2); // width
put_le32(data, 22, 1); // height
data[26] = 1; // color planes
data[28] = 8; // bits per pixel
data[46] = 2; // colors in the table
put_le32(data, 54, 0xFF0000);
put_le32(data, 58, 0x0000FF);
data.insert(data.end(), {0, 1, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF});
put_le32(data, 2, data.size());
ASSERT_TRUE(img.begin_decode(data.size()));
EXPECT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
EXPECT_EQ(img.get_pixel(0, 0).r, 255);
EXPECT_EQ(img.get_pixel(1, 0).b, 255);
}
TEST(DecodeTimeLimit, QoiIsDecodedOverSeveralCalls) {
RuntimeImage img(QOI, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
auto data = make_solid_qoi();
EXPECT_GT(decode_in_calls(img, data), 1) << "the whole image was decoded in one call";
for (auto [col, row] : {std::pair{0, 0}, std::pair{2000, 1000}, std::pair{4095, 2047}}) {
const Color pixel = img.get_pixel(col, row);
EXPECT_EQ(pixel.r, 30);
EXPECT_EQ(pixel.g, 120);
EXPECT_EQ(pixel.b, 200);
}
}
// A chunk split across two calls: the bytes used before the split count towards the decoded total
TEST(DecodeTimeLimit, QoiCountsTheBytesBeforeASplitChunk) {
RuntimeImage img(QOI, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
std::vector<uint8_t> data = {'q', 'o', 'i', 'f', 0, 0, 0, 2, 0, 0, 0, 1, 3, 0};
data.insert(data.end(), {0xFE, 10, 20, 30, 0xFE, 40, 50, 60, 0, 0, 0, 0, 0, 0, 0, 1});
QoiDecoder decoder(&img);
ASSERT_EQ(decoder.prepare(data.size()), 0);
// The header and the first chunk, and half of the second
EXPECT_EQ(decoder.decode(data.data(), 20), 18);
EXPECT_EQ(decoder.get_decoded_bytes(), 18u);
EXPECT_EQ(decoder.decode(data.data() + 18, data.size() - 18), static_cast<int>(data.size() - 18));
EXPECT_EQ(decoder.get_decoded_bytes(), data.size());
EXPECT_TRUE(decoder.is_finished());
}
} // namespace esphome::runtime_image::testing
@@ -5,9 +5,11 @@
#include <array>
#include <cstdlib>
#include <cstring>
#include <tuple>
#include <utility>
#include <vector>
#include "esphome/components/display/display_color_utils.h"
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
@@ -365,6 +367,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);
@@ -453,9 +472,37 @@ TEST(RuntimeImageDecoder, JpegDecodesToScaledRgb) {
}
// Target pixel index each source pixel paints when `source` pixels map onto `target`, the way
// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start
// prepare_draw_ rounds: source n covers targets ceil(n * target / source) up to the next start
static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; }
// Every target pixel of a PNG drawn at a size that is not a whole multiple: enlarged with spans 1 or 2 pixels wide, and
// shrunk with spans of 0 or 1 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] :
@@ -549,4 +596,152 @@ TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
EXPECT_FALSE(img.is_decode_finished()) << "no session means nothing is 'finished'";
}
// 5x3 QOI whose runs carry on from one row into the next
static const uint8_t QOI_RUNS[] = {
'q', 'o', 'i', 'f', 0, 0, 0, 5, 0, 0, 0, 3, 3, 0, // header
0xFE, 10, 20, 30, // 1 pixel
0xC5, // a run of 6
0xFE, 200, 100, 50, // 1 pixel
0xC6, // a run of 7
0, 0, 0, 0, 0, 0, 0, 1, // end marker
};
/// Decode a whole image into img
static void decode_into(RuntimeImage &img, ImageFormat format, const uint8_t *data, size_t len) {
std::vector<uint8_t> buffer(data, data + len);
ASSERT_TRUE(img.begin_decode(len, format));
size_t offset = 0;
while (offset < len) {
const int consumed = img.feed_data(buffer.data() + offset, len - offset);
ASSERT_GT(consumed, 0);
offset += consumed;
}
ASSERT_TRUE(img.end_decode());
}
// An opaque RGB565 image is written straight into the buffer. It must hold the same pixels, in the same places, as an
// RGB image filled through draw_pixel(), at the image's own size and scaled.
TEST(RuntimeImageDecoder, DirectRgb565MatchesTheGenericPath) {
const std::tuple<ImageFormat, const uint8_t *, size_t> images[] = {
{BMP, BMP_24BPP, sizeof(BMP_24BPP)},
{BMP, BMP_8BPP, sizeof(BMP_8BPP)},
{PNG, PNG_RGB, sizeof(PNG_RGB)},
{QOI, QOI_RUNS, sizeof(QOI_RUNS)},
};
for (auto [format, data, len] : images) {
for (auto [width, height] : {std::pair{0, 0}, std::pair{7, 5}, std::pair{2, 2}, std::pair{9, 2}}) {
RuntimeImage reference(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height);
decode_into(reference, format, data, len);
for (bool big_endian : {false, true}) {
RuntimeImage img(format, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, nullptr, big_endian, width,
height);
decode_into(img, format, data, len);
ASSERT_EQ(img.get_width(), reference.get_width());
ASSERT_EQ(img.get_height(), reference.get_height());
const uint8_t *pixels = img.get_data_start();
for (int i = 0; i < img.get_width() * img.get_height(); i++) {
const Color color = reference.get_pixel(i % img.get_width(), i / img.get_width());
const uint16_t expected = display::ColorUtil::color_to_565(color);
const uint16_t actual =
big_endian ? (pixels[i * 2] << 8 | pixels[i * 2 + 1]) : (pixels[i * 2] | pixels[i * 2 + 1] << 8);
EXPECT_EQ(actual, expected) << "image of " << len << " bytes into " << width << "x" << height
<< (big_endian ? " big-endian" : " little-endian") << ", pixel " << i;
}
}
}
}
}
// The runs fill the rows they carry on into
TEST(RuntimeImageDecoder, QoiRunsCrossRows) {
TestableRuntimeImage img(QOI);
ASSERT_TRUE(decode_all(img, QOI_RUNS, sizeof(QOI_RUNS)));
for (int i = 0; i < 15; i++) {
const Color pixel = img.get_pixel(i % 5, i / 5);
const bool second = i >= 7;
EXPECT_EQ(pixel.r, second ? 200 : 10) << "pixel " << i;
EXPECT_EQ(pixel.g, second ? 100 : 20) << "pixel " << i;
EXPECT_EQ(pixel.b, second ? 50 : 30) << "pixel " << i;
}
}
// 8x2 1bpp BMP: the top row has its right half set, the bottom row its left half (rows padded to 4 bytes)
static const uint8_t BMP_1BPP[] = {
0x42, 0x4D, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00,
0x08, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00,
0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00,
};
// BMP has no alpha, so no pixel may come out transparent, whatever transparency the image has
TEST(RuntimeImageDecoder, BmpPixelsAreOpaque) {
const std::pair<const uint8_t *, size_t> images[] = {
{BMP_24BPP, sizeof(BMP_24BPP)},
{BMP_8BPP, sizeof(BMP_8BPP)},
{BMP_1BPP, sizeof(BMP_1BPP)},
};
for (auto transparency : {image::TRANSPARENCY_ALPHA_CHANNEL, image::TRANSPARENCY_CHROMA_KEY}) {
for (auto type : {image::IMAGE_TYPE_RGB, image::IMAGE_TYPE_RGB565}) {
for (auto [data, len] : images) {
RuntimeImage img(BMP, type, transparency, nullptr, false, 0, 0);
decode_into(img, BMP, data, len);
for (int y = 0; y < img.get_height(); y++) {
for (int x = 0; x < img.get_width(); x++) {
EXPECT_EQ(img.get_pixel(x, y).w, 0xFF) << "image of " << len << " bytes, type " << type << ", transparency "
<< transparency << ", pixel (" << x << "," << y << ")";
}
}
}
}
}
}
TEST(RuntimeImageDecoder, Bmp1BppPixels) {
TestableRuntimeImage img(BMP, image::TRANSPARENCY_ALPHA_CHANNEL);
ASSERT_TRUE(decode_all(img, BMP_1BPP, sizeof(BMP_1BPP)));
ASSERT_EQ(img.get_width(), 8);
ASSERT_EQ(img.get_height(), 2);
for (int x = 0; x < 8; x++) {
EXPECT_EQ(img.get_pixel(x, 0).r, x >= 4 ? 0xFF : 0x00) << "pixel (" << x << ",0)";
EXPECT_EQ(img.get_pixel(x, 1).r, x < 4 ? 0xFF : 0x00) << "pixel (" << x << ",1)";
}
}
/// Reads a decoder's protected boundary tables
struct BoundaryTables : ImageDecoder {
static size_t x_capacity(const ImageDecoder &decoder) {
return (decoder.*(&BoundaryTables::x_boundaries_)).capacity();
}
static size_t y_capacity(const ImageDecoder &decoder) {
return (decoder.*(&BoundaryTables::y_boundaries_)).capacity();
}
static bool size_valid(const ImageDecoder &decoder) { return decoder.*(&BoundaryTables::size_valid_); }
static void prepare_draw(ImageDecoder &decoder, int width, int height) {
(decoder.*(&BoundaryTables::prepare_draw_))(width, height);
}
};
// A JPEG decoded at reduced size builds its tables once, for the decoded size, not first for the full size
TEST(RuntimeImageDecoder, JpegTablesAreBuiltForTheDecodedSize) {
TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, 1, 1);
ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT)));
ASSERT_EQ(static_cast<JpegDecoder *>(img.decoder())->get_scale(), 8);
EXPECT_EQ(BoundaryTables::x_capacity(*img.decoder()), 2u);
EXPECT_EQ(BoundaryTables::y_capacity(*img.decoder()), 2u);
}
// Without memory for the tables an RGB565 image is drawn pixel by pixel, rather than failing the decode
TEST(RuntimeImageDecoder, DirectDrawFallsBackWhenTablesCannotBeAllocated) {
TestableRuntimeImage img(BMP, image::IMAGE_TYPE_RGB565, false, 0, 0);
ASSERT_TRUE(img.begin_decode(sizeof(BMP_24BPP)));
BoundaryTables::prepare_draw(*img.decoder(), 3, 2);
ASSERT_TRUE(img.decoder()->uses_direct_rgb565());
// The x table already fits; a y table of SIZE_MAX entries can never be allocated
BoundaryTables::prepare_draw(*img.decoder(), 3, -2);
EXPECT_FALSE(img.decoder()->uses_direct_rgb565());
EXPECT_TRUE(BoundaryTables::size_valid(*img.decoder())) << "draw() would paint nothing";
EXPECT_EQ(BoundaryTables::x_capacity(*img.decoder()), 0u) << "the unused x table must be freed";
EXPECT_EQ(BoundaryTables::y_capacity(*img.decoder()), 0u);
}
} // namespace esphome::runtime_image::testing
@@ -0,0 +1,35 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstdlib>
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
class GrayscaleImage : public RuntimeImage {
public:
GrayscaleImage() : RuntimeImage(BMP, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE) {}
uint8_t pixel(int x) const { return this->buffer_[x]; }
};
// The integer weights stay within one step of the floating point formula they replace, and keep white at 255
TEST(GrayscalePixel, MatchesTheFloatingPointWeights) {
GrayscaleImage img;
ASSERT_GT(img.resize(256, 1), 0);
for (int r = 0; r < 256; r += 5) {
for (int g = 0; g < 256; g += 5) {
for (int b = 0; b < 256; b += 5) {
img.draw_pixel(0, 0, Color(r, g, b));
const int expected = static_cast<int>(0.2125 * r + 0.7154 * g + 0.0721 * b);
ASSERT_LE(std::abs(img.pixel(0) - expected), 1) << "r=" << r << " g=" << g << " b=" << b;
}
}
}
img.draw_pixel(1, 0, Color(255, 255, 255));
EXPECT_EQ(img.pixel(1), 255);
img.draw_pixel(2, 0, Color(0, 0, 0));
EXPECT_EQ(img.pixel(2), 0);
}
} // namespace esphome::runtime_image::testing
@@ -0,0 +1,204 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <utility>
#include <vector>
#include "esphome/components/runtime_image/png_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#include "esphome/core/hal.h"
#include "pngle.h"
namespace esphome::runtime_image::testing {
// 800x600 RGB PNG of a single color (30, 120, 200), 2312 bytes: little data, but many pixels to draw.
// The same bytes are in tests/components/online_image/test_download_end.cpp.
static const uint8_t PNG_SOLID[] = {
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x03,
0x20, 0x00, 0x00, 0x02, 0x58, 0x08, 0x02, 0x00, 0x00, 0x00, 0x15, 0x14, 0x15, 0x27, 0x00, 0x00, 0x08, 0xCF, 0x49,
0x44, 0x41, 0x54, 0x78, 0xDA, 0xED, 0xD6, 0x31, 0x11, 0x00, 0x30, 0x08, 0x00, 0xB1, 0x52, 0x21, 0xE8, 0x44, 0x22,
0xB2, 0x30, 0xC1, 0x31, 0x25, 0x12, 0x7E, 0xFA, 0xC8, 0xEA, 0x07, 0x00, 0xC0, 0x9E, 0x2F, 0x01, 0x00, 0x80, 0xC1,
0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00,
0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30,
0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58,
0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C,
0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83,
0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05,
0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80,
0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1,
0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00,
0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00,
0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30,
0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83,
0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C,
0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x83, 0x25, 0x01, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80,
0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30,
0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1,
0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00,
0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00,
0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00,
0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00,
0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0x00, 0x83,
0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16,
0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00,
0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80,
0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30,
0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1,
0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58,
0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02,
0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00,
0x00, 0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00,
0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00,
0x06, 0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0,
0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x25, 0x01, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06,
0x0B, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60,
0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B,
0x00, 0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01,
0x00, 0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00,
0x00, 0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00,
0x18, 0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00,
0x83, 0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18,
0x2C, 0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83,
0x05, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0,
0x00, 0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05,
0x00, 0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00,
0x00, 0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0x30, 0x58, 0x00, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00,
0x60, 0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00,
0x0C, 0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x18, 0x2C, 0x00, 0x00, 0x83, 0x05, 0x00, 0x60,
0xB0, 0x00, 0x00, 0x30, 0x58, 0x00, 0x00, 0x06, 0x0B, 0x00, 0xC0, 0x60, 0x01, 0x00, 0x60, 0xB0, 0x00, 0x00, 0x0C,
0x16, 0x00, 0x80, 0xC1, 0x02, 0x00, 0xC0, 0x60, 0x01, 0x00, 0xDC, 0x19, 0x79, 0xA5, 0x06, 0x0E, 0x33, 0xD4, 0xAF,
0xF2, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
};
// A 1x1 RGB PNG whose IEND chunk header crosses the end of the first 64 byte piece
static const uint8_t PNG_1X1[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 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};
// Draws that take a millisecond every fourth row, so decoding PNG_SOLID always outlasts the time limit of one call
class SlowDraws : public PngDecoder, public RuntimeImage {
public:
static void install(RuntimeImage &image) {
auto *decoder = static_cast<PngDecoder *>((image.*&SlowDraws::decoder_).get());
pngle_set_draw_callback(decoder->*&SlowDraws::pngle_, draw);
}
private:
static void draw(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, const uint8_t rgba[4]) {
if (x == 0 && y % 4 == 0) {
const uint32_t until = millis() + 2;
while (millis() < until) {
}
}
static_cast<PngDecoder *>(pngle_get_user_data(pngle))->draw(x, y, w, h, Color(rgba[0], rgba[1], rgba[2], rgba[3]));
}
};
// One feed_data() call stops after a short time, so a large image is decoded over several calls
TEST(PngTimeLimit, LargeImageIsDecodedOverSeveralCalls) {
RuntimeImage img(PNG, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
std::vector<uint8_t> data(PNG_SOLID, PNG_SOLID + sizeof(PNG_SOLID));
ASSERT_TRUE(img.begin_decode(data.size()));
SlowDraws::install(img);
size_t offset = 0;
int calls = 0;
while (offset < data.size() && calls < 1000) {
const int consumed = img.feed_data(data.data() + offset, data.size() - offset);
ASSERT_GT(consumed, 0);
offset += consumed;
calls++;
}
EXPECT_GT(calls, 1) << "the whole image was decoded in one call";
ASSERT_EQ(offset, data.size());
ASSERT_TRUE(img.end_decode());
ASSERT_EQ(img.get_width(), 800);
ASSERT_EQ(img.get_height(), 600);
for (auto [col, row] : {std::pair{0, 0}, std::pair{400, 300}, std::pair{799, 599}}) {
const Color pixel = img.get_pixel(col, row);
EXPECT_EQ(pixel.r, 30);
EXPECT_EQ(pixel.g, 120);
EXPECT_EQ(pixel.b, 200);
}
}
// A chunk header that crosses the end of a piece does not end the call
TEST(PngTimeLimit, ChunkAcrossAPieceDoesNotEndTheCall) {
RuntimeImage img(PNG, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
std::vector<uint8_t> data(PNG_1X1, PNG_1X1 + sizeof(PNG_1X1));
ASSERT_TRUE(img.begin_decode(data.size()));
EXPECT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
}
} // namespace esphome::runtime_image::testing
+6
View File
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# benchmark.yaml configures a udp packet_interface, whose to_code waits on the udp component variable.
manifest.enable_codegen()
+5
View File
@@ -0,0 +1,5 @@
udp:
addresses: ["192.168.1.255"]
packet_interface:
- platform: udp
@@ -0,0 +1,78 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/udp/packet_interface/udp_packet_interface.h"
#ifdef USE_NETWORK
namespace esphome::udp::testing {
// Copies a packet into a vector, for comparing in tests
static std::vector<uint8_t> to_vector(const esphome::packet_interface::PacketBuffer &data) {
std::vector<uint8_t> result(data.size());
data.copy_to(result.data(), result.size());
return result;
}
using packet_interface::PacketBuffer;
using packet_interface::PacketMetaData;
using Packet = std::vector<uint8_t>;
// The copy buffer is a base, so it is set up before the link's constructor sees it
struct UdpBufferStorage {
uint8_t tx_buffer_storage[8]{};
};
class TestUdp : public UdpBufferStorage, public UdpPacketInterface {
public:
explicit TestUdp(UDPComponent *udp) : UdpPacketInterface(udp, this->tx_buffer_storage) {
this->add_packet_interface_listener(
[this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); });
}
using UdpPacketInterface::receive_;
std::vector<Packet> received;
};
// The udp component is not set up, so it has no destinations and a send does nothing
class UdpPacketInterfaceTest : public ::testing::Test {
protected:
UDPComponent udp_;
TestUdp link_{&this->udp_};
};
TEST_F(UdpPacketInterfaceTest, ReceivedPacketsUpToMaxPassedUp) {
Packet packet{1, 2, 3, 4, 5, 6, 7, 8};
this->link_.receive_(packet);
Packet oversized{1, 2, 3, 4, 5, 6, 7, 8, 9};
this->link_.receive_(oversized);
EXPECT_EQ(this->link_.received, std::vector<Packet>{packet});
}
TEST_F(UdpPacketInterfaceTest, SendsContiguousAndSplitPackets) {
this->link_.setup();
Packet packet{1, 2, 3, 4};
EXPECT_TRUE(this->link_.send_to_interface(PacketBuffer(packet), {}));
uint8_t header = 0;
PacketBuffer body(packet);
EXPECT_TRUE(this->link_.send_to_interface(PacketBuffer(&header, 1, &body, nullptr, 0), {}));
// A packet split across buffers is joined in the copy buffer before it is sent
const auto *storage = this->link_.tx_buffer_storage;
EXPECT_EQ(Packet(storage, storage + 5), (Packet{0, 1, 2, 3, 4}));
}
TEST_F(UdpPacketInterfaceTest, OversizedPacketNotSent) {
this->link_.setup();
Packet packet{1, 2, 3, 4, 5, 6, 7, 8, 9};
EXPECT_FALSE(this->link_.send_to_interface(PacketBuffer(packet), {}));
uint8_t header = 0;
Packet body_data{1, 2, 3, 4, 5, 6, 7, 8};
PacketBuffer body(body_data);
EXPECT_FALSE(this->link_.send_to_interface(PacketBuffer(&header, 1, &body, nullptr, 0), {}));
}
} // namespace esphome::udp::testing
#endif
+214
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
import json
import logging
from pathlib import Path
@@ -24,13 +25,16 @@ from esphome.const import (
CONF_CLIENT_CERTIFICATE,
CONF_CLIENT_CERTIFICATE_KEY,
CONF_ESPHOME,
CONF_LOG_TOPIC,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_PAYLOAD,
CONF_PORT,
CONF_RETAIN,
CONF_SKIP_CERT_CN_CHECK,
CONF_TOPIC,
CONF_TOPIC_PREFIX,
)
from esphome.core import EsphomeError
from esphome.mqtt import (
@@ -99,6 +103,10 @@ def test_show_logs_requests_crash_log_when_device_reports_online(
on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline"))
client.publish.assert_not_called()
# The birth payload on another topic is only a log line
on_message(client, None, MagicMock(topic="test-device/debug", payload=b"online"))
client.publish.assert_not_called()
mock_print.reset_mock()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"online"))
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
@@ -136,6 +144,184 @@ def test_show_logs_prints_a_birth_message_inside_the_log_subscription(
assert mock_print.call_args.args[0].endswith("online")
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_logs_follows_the_mac_suffix_of_default_topics(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""With name_add_mac_suffix the default topics carry a MAC the CLI cannot know, so any suffix matches."""
show_logs(_mac_config(log_topic="test-device/debug"))
assert mock_initialize.call_args.args[1] == ["+/debug", "+/status"]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
for topic in (
"other-device/status",
"test-device/status",
"test-device-abc/status",
):
_online(on_message, client, topic)
client.publish.assert_not_called()
_online(on_message, client, "test-device-a1b2c3/status")
client.publish.assert_called_once_with(
"esphome/logs/test-device-a1b2c3", None, retain=False
)
on_message(client, None, MagicMock(topic="other-device/debug", payload=b"no"))
mock_print.assert_not_called()
on_message(
client, None, MagicMock(topic="test-device-a1b2c3/debug", payload=b"[I]: hi")
)
assert mock_print.call_args.args[0].endswith("[test-device-a1b2c3][I]: hi")
@pytest.mark.parametrize(
("mac_suffix", "options", "cli_topic", "subscriptions"),
[
pytest.param(
True,
{"log_topic": "custom/debug", "birth_topic": None},
None,
["custom/debug"],
id="custom log topic with the mac suffix",
),
pytest.param(
True,
{"birth_topic": None},
"test-device/debug",
["test-device/debug"],
id="command line topic as written",
),
pytest.param(
False,
{"prefix": "custom", "birth_topic": None},
None,
["custom/debug"],
id="logs under the topic prefix",
),
pytest.param(
True,
{"prefix": "custom", "log_topic": "test-device/debug"},
None,
["test-device/debug", "test-device/status"],
id="written out topics under a custom prefix",
),
],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_subscribes_to_literal_topics(
mock_initialize: MagicMock,
mac_suffix: bool,
options: dict,
cli_topic: str | None,
subscriptions: list[str],
) -> None:
"""Topics the firmware does not build from the MAC-suffixed name are subscribed to as written."""
config = _mac_config(**options)
config[CONF_ESPHOME][CONF_NAME_ADD_MAC_SUFFIX] = mac_suffix
show_logs(config, topic=cli_topic)
assert mock_initialize.call_args.args[1] == subscriptions
@patch("esphome.mqtt.initialize")
def test_show_logs_asks_only_the_board_a_command_line_topic_shows(
mock_initialize: MagicMock,
) -> None:
"""Replaying a crash clears it, so boards whose logs are not shown are left alone."""
show_logs(_mac_config(), topic="test-device-a1b2c3/debug")
assert mock_initialize.call_args.args[1] == [
"test-device-a1b2c3/debug",
"+/status",
]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
_online(on_message, client, "test-device-d4e5f6/status")
client.publish.assert_not_called()
_online(on_message, client, "test-device-a1b2c3/status")
client.publish.assert_called_once_with(
"esphome/logs/test-device-a1b2c3", None, retain=False
)
@pytest.mark.parametrize(
("cli_topic", "asked"),
[
pytest.param(None, True, id="no command line topic"),
pytest.param("custom/debug", True, id="the shared custom topic"),
pytest.param(
"test-device-a1b2c3/debug", False, id="a topic the board never uses"
),
],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_matches_a_command_line_topic_to_the_custom_log_topic(
mock_initialize: MagicMock, cli_topic: str | None, asked: bool
) -> None:
"""Every board's crash replay goes to the custom log topic, so --topic must show it for a board to be asked."""
show_logs(_mac_config(log_topic="custom/debug"), topic=cli_topic)
assert mock_initialize.call_args.args[1] == [
cli_topic or "custom/debug",
"+/status",
]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
_online(on_message, client, "test-device-a1b2c3/status")
if asked:
client.publish.assert_called_once_with(
"esphome/logs/test-device-a1b2c3", None, retain=False
)
else:
client.publish.assert_not_called()
@pytest.mark.parametrize(
("mqtt_config", "message"),
[
pytest.param(None, "MQTT isn't setup", id="no mqtt"),
pytest.param({CONF_LOG_TOPIC: None}, "set to null", id="null log topic"),
pytest.param(
{CONF_LOG_TOPIC: {}}, "not available", id="log topic without topic"
),
],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_without_a_log_topic(
mock_initialize: MagicMock,
mqtt_config: dict | None,
message: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without a log topic there is nothing to follow."""
config = _discovery_config()
if mqtt_config is None:
del config[CONF_MQTT]
else:
config[CONF_MQTT].update(mqtt_config)
assert show_logs(config) == 1
assert message in caplog.text
mock_initialize.assert_not_called()
@patch("esphome.mqtt.initialize")
def test_show_logs_skips_crash_log_requests_no_suffixed_board_hears(
mock_initialize: MagicMock,
) -> None:
"""A board with the MAC suffix only hears requests named after its own birth message."""
show_logs(_mac_config(birth_topic="custom/status", retain=False))
on_message = mock_initialize.call_args.args[2]
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
on_connect(client, None, None, 0, None)
_online(on_message, client, "custom/status")
client.publish.assert_not_called()
def test_get_esphome_device_ip_empty_broker() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when broker is empty."""
config = {
@@ -218,6 +404,34 @@ def test_get_esphome_device_ip_missing_name() -> None:
get_esphome_device_ip(config)
def _mac_config(
log_topic: str | None = None,
birth_topic: str | None = "test-device/status",
retain: bool = True,
prefix: str | None = None,
) -> dict:
"""A config with name_add_mac_suffix on and the given mqtt topics."""
config = _discovery_config()
config[CONF_ESPHOME][CONF_NAME_ADD_MAC_SUFFIX] = True
mqtt_conf = config[CONF_MQTT]
if prefix is not None:
mqtt_conf[CONF_TOPIC_PREFIX] = prefix
if log_topic is not None:
mqtt_conf[CONF_LOG_TOPIC] = {CONF_TOPIC: log_topic}
if birth_topic is not None:
mqtt_conf[CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: birth_topic,
CONF_PAYLOAD: "online",
CONF_RETAIN: retain,
}
return config
def _online(on_message: Callable[..., None], client: MagicMock, topic: str) -> None:
"""Deliver the birth payload on a topic."""
on_message(client, None, MagicMock(topic=topic, payload=b"online"))
def _discovery_config() -> dict:
return {
CONF_MQTT: {
+182
View File
@@ -1,17 +1,35 @@
"""Unit tests for the packet_interface component."""
import hashlib
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components import packet_interface
from esphome.components.packet_interface import CONF_MAX_PACKET_SIZE
from esphome.components.packet_interface.layers import (
CONF_RELIABILITY,
CONF_SESSION,
RELIABILITY,
STACK_ORDER,
LayerKind,
xxtea_encryption,
)
from esphome.components.packet_interface.layers.basic_session import CONF_KEEP_ALIVE
from esphome.components.packet_interface.layers.hdlc_reliability import (
CONF_MAX_QUEUE_LENGTH,
)
from esphome.components.uart import packet_interface as uart_packet_interface
from esphome.components.udp import packet_interface as udp_packet_interface
from esphome.const import CONF_ENCRYPTION
@pytest.mark.parametrize(
("schema", "default", "limit"),
[
(uart_packet_interface.CONFIG_SCHEMA, 508, 4096),
(udp_packet_interface.CONFIG_SCHEMA, 508, 508),
],
)
def test_platform_max_packet_size(
@@ -32,3 +50,167 @@ def test_schema_default(setup_core) -> None:
packet_interface.PacketInterface, 100
)
assert schema({})[CONF_MAX_PACKET_SIZE] == 100
def test_reliability_defaults(setup_core) -> None:
"""An empty reliability key selects the HDLC layer with its defaults."""
config = uart_packet_interface.CONFIG_SCHEMA({CONF_RELIABILITY: None})
reliability = config[CONF_RELIABILITY]
assert reliability["type"] == "hdlc"
assert reliability["timeout"].total_milliseconds == 200
assert reliability[CONF_MAX_QUEUE_LENGTH] == 4
assert reliability["max_retries"] == 5
def test_reliability_queue_length_limit(setup_core) -> None:
"""The queue cannot hold more frames than 3 bit sequence numbers allow."""
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA(
{CONF_RELIABILITY: {CONF_MAX_QUEUE_LENGTH: 8}}
)
def test_reliability_timeout_minimum(setup_core) -> None:
"""A timeout shorter than a few loop passes cannot work."""
uart_packet_interface.CONFIG_SCHEMA({CONF_RELIABILITY: {"timeout": "50ms"}})
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA({CONF_RELIABILITY: {"timeout": "40ms"}})
def test_ack_delay_shorter_than_timeout(setup_core) -> None:
"""The acknowledgement must not wait longer than the peer's timeout."""
reliability = uart_packet_interface.CONFIG_SCHEMA(
{CONF_RELIABILITY: {"timeout": "500ms", "ack_delay": "100ms"}}
)[CONF_RELIABILITY]
assert reliability["ack_delay"].total_milliseconds == 100
with pytest.raises(cv.Invalid, match="shorter"):
uart_packet_interface.CONFIG_SCHEMA(
{CONF_RELIABILITY: {"timeout": "500ms", "ack_delay": "500ms"}}
)
def test_layer_types_registered() -> None:
"""Importing packet_interface registers the built-in layer types."""
assert {kind.key: set(kind.types) for kind in STACK_ORDER} == {
CONF_ENCRYPTION: {"xxtea"},
CONF_RELIABILITY: {"hdlc"},
CONF_SESSION: {"basic"},
}
async def _build(config: dict, lower: Any) -> Any:
return lower
def test_registered_type_selectable(setup_core) -> None:
"""A newly registered layer type can be selected with type."""
kind = LayerKind("test_kind", "test")
kind.register("test", schema=cv.Schema({}))(_build)
config = kind.schema()({"type": "test"})
assert config["type"] == "test"
assert kind.get_type(config).build is _build
def test_duplicate_layer_type_rejected(setup_core) -> None:
"""Registering a type under a name already in use is an error, not a silent replacement."""
kind = LayerKind("test_kind", "test")
kind.register("test", schema=cv.Schema({}))(_build)
with pytest.raises(ValueError, match="already registered"):
kind.register("test", schema=cv.Schema({}))(_build)
def test_late_registration_rejected(setup_core) -> None:
"""A type registered after the schemas were built would be missing from them, so it is an error."""
with pytest.raises(ValueError, match="after the schemas were built"):
RELIABILITY.register("late", schema=cv.Schema({}))(_build)
def test_session_defaults(setup_core) -> None:
"""An empty session key selects the basic session, timing out after three keep-alives."""
session = uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: None})[CONF_SESSION]
assert session["type"] == "basic"
assert session[CONF_KEEP_ALIVE].total_milliseconds == 10000
assert session["timeout"].total_milliseconds == 30000
def test_session_timeout(setup_core) -> None:
"""The timeout follows keep_alive unless given, and must be longer than it."""
session = uart_packet_interface.CONFIG_SCHEMA(
{CONF_SESSION: {CONF_KEEP_ALIVE: "2s"}}
)[CONF_SESSION]
assert session["timeout"].total_milliseconds == 6000
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA(
{CONF_SESSION: {CONF_KEEP_ALIVE: "5s", "timeout": "5s"}}
)
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: {CONF_KEEP_ALIVE: "500ms"}})
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: {CONF_KEEP_ALIVE: "2h"}})
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA({CONF_SESSION: {"timeout": "4h"}})
def test_encryption_key_only(setup_core) -> None:
"""The key can be given on its own, and selects XXTEA."""
encryption = uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: "secret"})[
CONF_ENCRYPTION
]
assert encryption["type"] == "xxtea"
assert encryption["key"] == "secret"
assert encryption["rolling_code_enable"] is False
def test_encryption_requires_key(setup_core) -> None:
"""An encryption layer without a key is rejected."""
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: {}})
with pytest.raises(cv.Invalid):
uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: ""})
def test_rolling_code_requires_session(setup_core) -> None:
"""The rolling code needs the session's keep-alives, so the peer sends this device's boot number back."""
rolling = {CONF_ENCRYPTION: {"key": "secret", "rolling_code_enable": True}}
with pytest.raises(cv.Invalid, match="session"):
uart_packet_interface.CONFIG_SCHEMA(rolling)
uart_packet_interface.CONFIG_SCHEMA({**rolling, CONF_SESSION: None})
uart_packet_interface.CONFIG_SCHEMA({CONF_ENCRYPTION: "secret"})
@pytest.mark.parametrize(
("config", "loads"),
[
({}, []),
({CONF_RELIABILITY: None, CONF_SESSION: None}, []),
({CONF_SESSION: {"binary_sensor": {"name": "Link"}}}, ["binary_sensor"]),
({CONF_ENCRYPTION: "secret"}, ["xxtea"]),
],
)
def test_auto_load_follows_layers(
setup_core, config: dict[str, Any], loads: list[str]
) -> None:
"""Only the components that the configured layers use are loaded."""
validated = uart_packet_interface.CONFIG_SCHEMA(config)
assert packet_interface.AUTO_LOAD([validated]) == loads
def test_auto_load_without_config() -> None:
"""Tooling that lists dependencies gets everything a layer could load."""
assert packet_interface.AUTO_LOAD() == ["binary_sensor", "xxtea"]
def test_xxtea_key_stretched() -> None:
"""The key comes from PBKDF2 of the key text, not a single hash a captured packet could check guesses against."""
key = xxtea_encryption.derive_key("Johnny Cash")
digest = hashlib.pbkdf2_hmac(
"sha256",
b"Johnny Cash",
b"esphome packet_interface xxtea",
600_000,
)
assert key == [int.from_bytes(digest[i : i + 4], "little") for i in range(0, 32, 4)]
assert len(key) == 8 and all(0 <= word < 2**32 for word in key)
assert key != xxtea_encryption.derive_key("Johnny Cash ")
sha = hashlib.sha256(b"Johnny Cash").digest()
assert key[0] != int.from_bytes(sha[:4], "little")