mirror of
https://github.com/esphome/esphome.git
synced 2026-10-24 15:05:33 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0e32552b | ||
|
|
809bc71649 | ||
|
|
7bec8aeb3d | ||
|
|
2897963905 | ||
|
|
ace4651476 | ||
|
|
b8d35764e7 | ||
|
|
afcf345264 | ||
|
|
19ce03f4bf | ||
|
|
83f460e964 | ||
|
|
e964a4d11a | ||
|
|
29a928ed5a | ||
|
|
2cbc1bc02d | ||
|
|
701e5746b0 | ||
|
|
9d30bcc721 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -296,7 +296,9 @@ def _external_manifests() -> list[dict[str, Any]]:
|
||||
found[component_dir] = source
|
||||
break
|
||||
else:
|
||||
if not component_dir.is_relative_to(loader.CORE_COMPONENTS_PATH):
|
||||
# Built in means anywhere in the package: the esphome domain itself
|
||||
# is esphome/core, not under esphome/components
|
||||
if not component_dir.is_relative_to(loader.CORE_COMPONENTS_PATH.parent):
|
||||
_LOGGER.warning(
|
||||
"SBOM: component '%s' in %s matches no external component "
|
||||
"source and is listed as part of ESPHome",
|
||||
|
||||
+88
-30
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -386,7 +386,7 @@ def test_external_manifests_skip_other_components(
|
||||
components_dir = tmp_path / "components"
|
||||
get_sources().append(ExternalSource(components_dir=components_dir))
|
||||
_add_external_component(components_dir, "widget")
|
||||
_add_external_component(tmp_path / "builtin", "wifi")
|
||||
_add_external_component(tmp_path / "pkg" / "components", "wifi")
|
||||
CORE.loaded_integrations.update({"missing", "namespace"})
|
||||
sbom.loader._COMPONENT_CACHE["namespace"] = SimpleNamespace(
|
||||
module=SimpleNamespace()
|
||||
@@ -399,7 +399,9 @@ def test_external_manifests_skip_other_components(
|
||||
side_effect=sbom.loader._COMPONENT_CACHE.get,
|
||||
),
|
||||
patch.object(
|
||||
sbom.loader, "CORE_COMPONENTS_PATH", (tmp_path / "builtin").resolve()
|
||||
sbom.loader,
|
||||
"CORE_COMPONENTS_PATH",
|
||||
(tmp_path / "pkg" / "components").resolve(),
|
||||
),
|
||||
):
|
||||
manifests = sbom._external_manifests()
|
||||
@@ -703,3 +705,12 @@ def test_checkout_remote_detached(tmp_path: Path) -> None:
|
||||
"https://user:token@github.com/someone/esphome.git",
|
||||
)
|
||||
assert not any(call.args[0][1] == "config" for call in run.call_args_list)
|
||||
|
||||
|
||||
def test_external_manifests_esphome_core_is_built_in(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The esphome domain resolves to esphome/core, which is still part of ESPHome."""
|
||||
CORE.loaded_integrations.add("esphome")
|
||||
assert sbom._external_manifests() == []
|
||||
assert "SBOM" not in caplog.text
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user