Compare commits

...
10 Commits
Author SHA1 Message Date
Clyde Stubbs 2cbc1bc02d [http_request] Run requests without blocking the loop (#21486) 2026-10-24 06:14:45 +00:00
Petar Petrov 701e5746b0 [demo][lvgl][const] Move CONF_BUTTONS to the shared component constants (#21551) 2026-10-23 20:14:36 -10:00
Keith BurzinskiandClaude Opus 5.5 9d30bcc721 [core] Do not warn that ESPHome's own core is an unmatched SBOM component (#21546)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 00:52:50 -05:00
J. Nick Koston acb89a8676 [duco] Use the shared sntp_time id in the component test (#21548) 2026-10-23 19:03:42 -10:00
Keith BurzinskiandClaude Opus 5.5 08d231991b [core] Tighten SBOM source detection for local installs (#21529)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 22:55:48 -05:00
253db1e0d5 [hitachi_ac344] Add quiet and vertical swing mode (#18229)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 17:50:48 -10:00
J. Nick Koston bb07d04c90 [tests] Drive the clock in the UDP OTA no-progress test (#21192) 2026-10-23 17:41:33 -10:00
3c1e53628f [hitachi_ac344] Add mildew-proof switch (#18230)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 17:32:27 -10:00
d515e8b2e3 [sim800l] Add on_dtmf_received trigger (#21495)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-23 17:31:36 -10:00
Keith BurzinskiandClaude Opus 5.5 49d4b83dd2 [core] Add esphome.project.sbom for SBOM supplier and contact (#21455)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 22:29:53 -05:00
37 changed files with 1990 additions and 240 deletions

No files matched your search

+2
View File
@@ -84,3 +84,5 @@ FROM base-${BUILD_TYPE} AS final
# Copy esphome and install
COPY . /esphome
RUN uv pip install --no-cache-dir -e /esphome
# The tree has no .git; lets SBOM manifests describe it as the release it was built from
ENV ESPHOME_IS_DOCKER_IMAGE=true
+3 -2
View File
@@ -24,7 +24,9 @@ climate::ClimateTraits ClimateIR::traits() {
return traits;
}
void ClimateIR::setup() {
void ClimateIR::setup() { this->setup_(this->restore_state_()); }
void ClimateIR::setup_(optional<climate::ClimateDeviceRestoreState> restore) {
if (this->sensor_) {
this->sensor_->add_on_state_callback([this](float state) {
this->current_temperature = state;
@@ -43,7 +45,6 @@ void ClimateIR::setup() {
}
// restore set points
auto restore = this->restore_state_();
if (restore.has_value()) {
restore->apply(this);
} else {
@@ -60,6 +60,10 @@ class ClimateIR : public Component,
/// Transmit via IR the state of this climate controller.
virtual void transmit_state() = 0;
/// setup() with the restored state already loaded, for subclasses that read it first. Load it only once:
/// every restore_state_() call takes a new preference slot on some platforms.
void setup_(optional<climate::ClimateDeviceRestoreState> restore);
// Dummy implement on_receive so implementation is optional for inheritors
bool on_receive(remote_base::RemoteReceiveData data) override { return false; };
+1
View File
@@ -19,6 +19,7 @@ CONF_BITRATE = "bitrate"
CONF_BLUE_CHANNEL = "blue_channel"
CONF_BORDER_ROUTER = "border_router"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BUTTONS = "buttons"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANNEL_COLORS = "channel_colors"
CONF_CLEAR_CHANNEL = "clear_channel"
+1 -1
View File
@@ -18,6 +18,7 @@ from esphome.components import (
text_sensor,
valve,
)
from esphome.components.const import CONF_BUTTONS
import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
@@ -167,7 +168,6 @@ VALVE_TYPES = {
CONF_ALARM_CONTROL_PANELS = "alarm_control_panels"
CONF_BUTTONS = "buttons"
CONF_CLIMATES = "climates"
CONF_COVERS = "covers"
CONF_DATETIMES = "datetimes"
@@ -1,6 +1,8 @@
#include "hitachi_ac344.h"
#include "esphome/core/helpers.h"
#include <cstring>
namespace esphome::hitachi_ac344 {
ESPHOME_LOG_TAG(TAG, "climate.hitachi_ac344");
@@ -101,21 +103,7 @@ void HitachiClimate::set_fan_(uint8_t speed) {
remote_state_[29] = 0x01;
}
void HitachiClimate::set_swing_v_toggle_(bool on) {
uint8_t button = get_button_(); // Get the current button value.
if (on) {
button = HITACHI_AC344_BUTTON_SWINGV; // Set the button to SwingV.
} else if (button == HITACHI_AC344_BUTTON_SWINGV) { // Asked to unset it
// It was set previous, so use Power as a default
button = HITACHI_AC344_BUTTON_POWER;
}
set_button_(button);
}
bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC344_BUTTON_SWINGV; }
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
}
@@ -129,18 +117,59 @@ void HitachiClimate::set_swing_h_(uint8_t position) {
return;
}
set_bits(&remote_state_[HITACHI_AC344_SWINGH_BYTE], HITACHI_AC344_SWINGH_OFFSET, HITACHI_AC344_SWINGH_SIZE, position);
set_button_(HITACHI_AC344_BUTTON_SWINGH);
}
uint8_t HitachiClimate::get_swing_h_() {
return GETBITS8(remote_state_[HITACHI_AC344_SWINGH_BYTE], HITACHI_AC344_SWINGH_OFFSET, HITACHI_AC344_SWINGH_SIZE);
}
uint8_t HitachiClimate::get_button_() { return remote_state_[HITACHI_AC344_BUTTON_BYTE]; }
void HitachiClimate::set_button_(uint8_t button) { remote_state_[HITACHI_AC344_BUTTON_BYTE] = button; }
void HitachiClimate::transmit_state() {
static bool swing_vertical(climate::ClimateSwingMode mode) {
return mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH;
}
bool HitachiClimate::get_mildew_proof() const {
return GETBIT8(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], HITACHI_AC344_MILDEWPROOF_OFFSET);
}
void HitachiClimate::set_mildew_proof(bool on) {
// A repeated write resends the frame, so a missed burst can be retried
assign_bits(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], 1 << HITACHI_AC344_MILDEWPROOF_OFFSET, on);
this->transmit_state_(HITACHI_AC344_BUTTON_MILDEWPROOF);
this->publish_mildew_proof_();
}
void HitachiClimate::publish_mildew_proof_() {
#ifdef USE_SWITCH
if (this->mildew_proof_switch_ != nullptr)
this->mildew_proof_switch_->publish_state(this->get_mildew_proof());
#endif
}
void HitachiClimate::setup() {
// Seed the frame before the base publishes the restored state; an on_state automation may transmit from that
// callback. Nothing is sent at boot, the restored settings ride along with the next frame.
auto restore = this->restore_state_();
if (restore.has_value()) {
set_swing_v_(swing_vertical(restore->swing_mode));
}
#ifdef USE_SWITCH
// The restored value is the switch state, which an inverted switch flips relative to the frame bit
if (this->mildew_proof_switch_ != nullptr) {
if (auto initial = this->mildew_proof_switch_->get_initial_state_with_restore_mode()) {
assign_bits(this->remote_state_[HITACHI_AC344_MILDEWPROOF_BYTE], 1 << HITACHI_AC344_MILDEWPROOF_OFFSET,
*initial != this->mildew_proof_switch_->is_inverted());
}
}
#endif
this->setup_(restore);
this->publish_mildew_proof_();
}
void HitachiClimate::transmit_state() { this->transmit_state_(HITACHI_AC344_BUTTON_POWER); }
void HitachiClimate::transmit_state_(uint8_t button) {
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
set_mode_(HITACHI_AC344_MODE_COOL);
@@ -167,6 +196,9 @@ void HitachiClimate::transmit_state() {
set_temp_(static_cast<uint8_t>(this->target_temperature));
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
case climate::CLIMATE_FAN_QUIET:
set_fan_(HITACHI_AC344_FAN_MIN);
break;
case climate::CLIMATE_FAN_LOW:
set_fan_(HITACHI_AC344_FAN_LOW);
break;
@@ -182,27 +214,18 @@ void HitachiClimate::transmit_state() {
set_fan_(HITACHI_AC344_FAN_AUTO);
}
switch (this->swing_mode) {
case climate::CLIMATE_SWING_BOTH:
set_swing_v_(true);
set_swing_h_(HITACHI_AC344_SWINGH_AUTO);
break;
case climate::CLIMATE_SWING_VERTICAL:
set_swing_v_(true);
set_swing_h_(HITACHI_AC344_SWINGH_MIDDLE);
break;
case climate::CLIMATE_SWING_HORIZONTAL:
set_swing_v_(false);
set_swing_h_(HITACHI_AC344_SWINGH_AUTO);
break;
case climate::CLIMATE_SWING_OFF:
set_swing_v_(false);
set_swing_h_(HITACHI_AC344_SWINGH_MIDDLE);
break;
}
const bool swing_v = swing_vertical(this->swing_mode);
const bool swing_h =
this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL || this->swing_mode == climate::CLIMATE_SWING_BOTH;
set_swing_h_(swing_h ? HITACHI_AC344_SWINGH_AUTO : HITACHI_AC344_SWINGH_MIDDLE);
// The unit ignores the swing bit and toggles on the swing button, so only a vertical swing change presses it;
// everything else sends the given button
set_button_(swing_v != get_swing_v_() ? HITACHI_AC344_BUTTON_SWINGV : button);
set_swing_v_(swing_v);
// TODO: find change value to set button, now always set to power button
set_button_(HITACHI_AC344_BUTTON_POWER);
// The remote sends this when powering off with mildew proof on
if (this->mode == climate::CLIMATE_MODE_OFF && this->get_mildew_proof())
remote_state_[9] = 0xA5;
invert_byte_pairs(remote_state_ + 3, HITACHI_AC344_STATE_LENGTH - 3);
@@ -275,6 +298,8 @@ bool HitachiClimate::parse_fan_(const uint8_t remote_state[]) {
ESP_LOGV(TAG, "Fan: %02X %02X", remote_state[HITACHI_AC344_FAN_BYTE], fan_mode);
switch (fan_mode) {
case HITACHI_AC344_FAN_MIN:
this->fan_mode = climate::CLIMATE_FAN_QUIET;
break;
case HITACHI_AC344_FAN_LOW:
this->fan_mode = climate::CLIMATE_FAN_LOW;
break;
@@ -298,11 +323,13 @@ bool HitachiClimate::parse_swing_(const uint8_t remote_state[]) {
uint8_t swing_modeh =
GETBITS8(remote_state[HITACHI_AC344_SWINGH_BYTE], HITACHI_AC344_SWINGH_OFFSET, HITACHI_AC344_SWINGH_SIZE);
ESP_LOGV(TAG, "SwingH: %02X %02X", remote_state[HITACHI_AC344_SWINGH_BYTE], swing_modeh);
bool swing_v = GETBIT8(remote_state[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET);
ESP_LOGV(TAG, "SwingV: %02X %s", remote_state[HITACHI_AC344_SWINGV_BYTE], ONOFF(swing_v));
if ((swing_modeh & 0x3) == 0x3) {
this->swing_mode = climate::CLIMATE_SWING_OFF;
if (swing_modeh != HITACHI_AC344_SWINGH_AUTO) {
this->swing_mode = swing_v ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
} else {
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
this->swing_mode = swing_v ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_HORIZONTAL;
}
return true;
@@ -345,9 +372,10 @@ bool HitachiClimate::on_receive(remote_base::RemoteReceiveData data) {
this->parse_fan_(recv_state);
// parse swingv
this->parse_swing_(recv_state);
// Copy first so an on_state automation transmits from the received frame
memcpy(remote_state_, recv_state, HITACHI_AC344_STATE_LENGTH);
this->publish_state();
for (uint8_t i = 0; i < HITACHI_AC344_STATE_LENGTH; i++)
remote_state_[i] = recv_state[i];
this->publish_mildew_proof_();
return true;
}
@@ -2,6 +2,9 @@
#include "esphome/core/log.h"
#include "esphome/components/climate_ir/climate_ir.h"
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::hitachi_ac344 {
@@ -79,9 +82,18 @@ class HitachiClimate final : public climate_ir::ClimateIR {
public:
HitachiClimate()
: climate_ir::ClimateIR(HITACHI_AC344_TEMP_MIN, HITACHI_AC344_TEMP_MAX, 1.0F, true, true,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL}) {}
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
bool get_mildew_proof() const;
/// Mildew proof keeps the fan running after power off to dry the unit
void set_mildew_proof(bool on);
#ifdef USE_SWITCH
void set_mildew_proof_switch(switch_::Switch *sw) { this->mildew_proof_switch_ = sw; }
#endif
void setup() override;
protected:
uint8_t remote_state_[HITACHI_AC344_STATE_LENGTH]{0x01, 0x10, 0x00, 0x40, 0x00, 0xFF, 0x00, 0xCC, 0x00, 0x00, 0x00,
@@ -89,8 +101,14 @@ class HitachiClimate final : public climate_ir::ClimateIR {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
0x80, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t previous_temp_{27};
#ifdef USE_SWITCH
switch_::Switch *mildew_proof_switch_{nullptr};
#endif
// Transmit via IR the state of this climate controller.
void transmit_state() override;
// The frame with the button byte the remote sends for this change
void transmit_state_(uint8_t button);
void publish_mildew_proof_();
bool get_power_();
void set_power_(bool on);
uint8_t get_mode_();
@@ -98,13 +116,10 @@ class HitachiClimate final : public climate_ir::ClimateIR {
void set_temp_(uint8_t celsius, bool set_previous = true);
uint8_t get_fan_();
void set_fan_(uint8_t speed);
void set_swing_v_toggle_(bool on);
bool get_swing_v_toggle_();
void set_swing_v_(bool on);
bool get_swing_v_();
void set_swing_h_(uint8_t position);
uint8_t get_swing_h_();
uint8_t get_button_();
void set_button_(uint8_t button);
// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
@@ -0,0 +1,30 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from ..climate import HitachiClimate
CONF_HITACHI_AC344_ID = "hitachi_ac344_id"
CONF_MILDEW_PROOF = "mildew_proof"
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_HITACHI_AC344_ID): cv.use_id(HitachiClimate),
cv.Required(CONF_MILDEW_PROOF): switch.switch_schema(
switch.ApplySwitch,
icon="mdi:shield-check-outline",
entity_category=ENTITY_CATEGORY_CONFIG,
default_restore_mode="RESTORE_DEFAULT_OFF",
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_HITACHI_AC344_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(
CONF_MILDEW_PROOF, hub.set_mildew_proof_switch, apply="set_mildew_proof(state)"
)
+10 -3
View File
@@ -56,6 +56,7 @@ CONF_HTTP_REQUEST_ID = "http_request_id"
CONF_USERAGENT = "useragent"
CONF_FOLLOW_REDIRECTS = "follow_redirects"
CONF_REDIRECT_LIMIT = "redirect_limit"
CONF_MAX_CONCURRENT_REQUESTS = "max_concurrent_requests"
CONF_BUFFER_SIZE_RX = "buffer_size_rx"
CONF_BUFFER_SIZE_TX = "buffer_size_tx"
CONF_TLS_BUFFER_SIZE_RX = "tls_buffer_size_rx"
@@ -144,6 +145,10 @@ CONFIG_SCHEMA = cv.All(
): cv.string,
cv.Optional(CONF_FOLLOW_REDIRECTS, True): cv.boolean,
cv.Optional(CONF_REDIRECT_LIMIT, 3): cv.int_,
# Each request holds a client, and a TLS client about 40 kB
cv.Optional(CONF_MAX_CONCURRENT_REQUESTS, default=1): cv.int_range(
min=1, max=4
),
cv.Optional(
CONF_TIMEOUT, default="4.5s"
): cv.positive_time_period_milliseconds,
@@ -193,6 +198,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_useragent(config[CONF_USERAGENT]))
cg.add(var.set_follow_redirects(config[CONF_FOLLOW_REDIRECTS]))
cg.add(var.set_redirect_limit(config[CONF_REDIRECT_LIMIT]))
if (max_concurrent := config[CONF_MAX_CONCURRENT_REQUESTS]) != 1:
cg.add(var.set_max_concurrent_requests(max_concurrent))
if CORE.is_esp8266 and not config[CONF_ESP8266_DISABLE_SSL_SUPPORT]:
cg.add_define("USE_HTTP_REQUEST_ESP8266_HTTPS")
@@ -312,19 +319,19 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend(
"http_request.get",
HttpRequestSendAction,
HTTP_REQUEST_GET_ACTION_SCHEMA,
synchronous=True,
synchronous=False,
)
@automation.register_action(
"http_request.post",
HttpRequestSendAction,
HTTP_REQUEST_POST_ACTION_SCHEMA,
synchronous=True,
synchronous=False,
)
@automation.register_action(
"http_request.send",
HttpRequestSendAction,
HTTP_REQUEST_SEND_ACTION_SCHEMA,
synchronous=True,
synchronous=False,
)
async def http_request_action_to_code(
config: ConfigType,
@@ -1,5 +1,7 @@
#include "http_request.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -14,8 +16,10 @@ void HttpRequestComponent::dump_config() {
" Timeout: %" PRIu32 "ms\n"
" User-Agent: %s\n"
" Follow redirects: %s\n"
" Redirect limit: %d",
this->timeout_, this->useragent_, YESNO(this->follow_redirects_), this->redirect_limit_);
" Redirect limit: %d\n"
" Max concurrent requests: %u",
this->timeout_, this->useragent_, YESNO(this->follow_redirects_), this->redirect_limit_,
this->max_concurrent_);
if (this->watchdog_timeout_ > 0) {
ESP_LOGCONFIG(TAG, " Watchdog Timeout: %" PRIu32 "ms", this->watchdog_timeout_);
}
@@ -32,4 +36,209 @@ std::string HttpContainer::get_response_header(const std::string &header_name) {
return "";
}
void PendingRequest::free_buffer() {
if (this->buf != nullptr)
RAMAllocator<uint8_t>().deallocate(this->buf, this->buf_size);
this->buf = nullptr;
}
void HttpRequestComponent::setup() {
// An on_boot automation may already have queued a request
if (this->requests_ == nullptr)
this->disable_loop();
}
void HttpRequestComponent::loop() {
if (!this->process_())
this->disable_loop();
}
void HttpRequestComponent::on_safe_shutdown() {
// Called before any on_shutdown(). Drop the requests already made, so that they do not hold up the reboot;
// only the ones that on_shutdown automations make are sent.
this->shutting_down_ = true;
this->busy_++;
unsigned dropped = 0;
for (PendingRequest *request = this->requests_; request != nullptr; request = request->next) {
if (request->finished || request->cancelled)
continue;
dropped++;
request->cancelled = true;
// As script.stop would, so that a script that was waiting for it can run again from on_shutdown
request->handler(request, RequestEvent::REQUEST_EVENT_ABANDON);
}
this->busy_--;
if (dropped != 0) {
ESP_LOGW(TAG, "Shutting down; %u requests dropped", dropped);
}
if (this->busy_ == 0)
this->remove_dead_();
}
void HttpRequestComponent::on_shutdown() {
// Shutdown runs no loop(), so finish the requests here, blocking as they did before. A forced reboot calls
// only this, so it also finishes the requests made before it.
this->shutting_down_ = true;
this->drain_();
}
void HttpRequestComponent::submit(PendingRequest *request) {
this->busy_++;
uint8_t running = 0;
uint8_t waiting = 0;
PendingRequest **link = &this->requests_;
for (; *link != nullptr; link = &(*link)->next) {
const PendingRequest *other = *link;
if (other->finished || other->cancelled)
continue;
if (other->container == nullptr) {
waiting++;
} else {
running++;
}
}
*link = request;
this->enable_loop();
if (waiting >= MAX_QUEUED) {
ESP_LOGW(TAG, "Too many requests waiting; request dropped");
request->failed = true;
this->finish_(request);
} else if (waiting == 0 && running < this->max_concurrent_) {
// Start at once; one that cannot start is reported at once
this->begin_request_(request);
if (request->failed)
this->finish_(request);
}
this->busy_--;
if (this->busy_ != 0)
return;
if (this->shutting_down_) {
this->drain_();
} else {
this->remove_dead_();
}
}
void HttpRequestComponent::cancel(void *owner) {
for (PendingRequest *request = this->requests_; request != nullptr; request = request->next) {
if (request->owner == owner)
request->cancelled = true;
}
if (this->busy_ == 0)
this->remove_dead_();
}
bool HttpRequestComponent::process_() {
this->busy_++;
// The first max_concurrent_ live requests run; a request finished here frees its slot on the next pass
uint8_t slots = this->max_concurrent_;
for (PendingRequest *request = this->requests_; request != nullptr && slots != 0; request = request->next) {
if (request->finished || request->cancelled)
continue;
slots--;
if (request->container == nullptr)
this->begin_request_(request);
if (!request->cancelled && (request->failed || this->advance_(request)))
this->finish_(request);
}
this->busy_--;
if (this->busy_ == 0)
this->remove_dead_();
return this->requests_ != nullptr;
}
void HttpRequestComponent::drain_() {
// A call further up holds request pointers, and runs the new requests itself
if (this->busy_ != 0)
return;
while (this->process_()) {
App.feed_wdt();
delay(1);
}
}
void HttpRequestComponent::begin_request_(PendingRequest *request) {
request->start_time = millis();
request->handler(request, RequestEvent::REQUEST_EVENT_START);
// Stopped from the request's own lambdas: it is removed without being polled
if (!request->cancelled && request->container == nullptr)
request->failed = true;
}
bool HttpRequestComponent::advance_(PendingRequest *request) {
if (!request->reading) {
auto progress = request->container->poll();
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING)
return false;
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED) {
request->failed = true;
return true;
}
if (!request->capture)
return true;
request->buf = RAMAllocator<uint8_t>().allocate(request->buf_size);
if (request->buf == nullptr) {
// As before, on_response still fires, with an empty body
ESP_LOGW(TAG, "Could not allocate %zu bytes for the response body", request->buf_size);
return true;
}
request->reading = true;
// millis(), not the loop time: poll() may have blocked earlier in this pass
request->last_data_time = millis();
}
// NOTE: HttpContainer::read() has non-BSD socket semantics - see http_request.h
// millis(), not the loop time: the budget is shorter than one pass of the loop
const uint32_t start = millis();
while (request->read_index < request->buf_size) {
int read_or_error =
request->container->read_available(request->buf + request->read_index, request->buf_size - request->read_index);
const uint32_t now = millis();
// As before, a body that is cut off still fires on_response with what was read
if (read_or_error < 0) {
ESP_LOGW(TAG, "Response body read failed after %zu bytes", request->read_index);
return true;
}
if (read_or_error == 0) {
if (request->container->is_read_complete())
return true;
if (now - request->last_data_time < request->timeout)
return false;
ESP_LOGW(TAG, "Response body timed out after %zu bytes", request->read_index);
return true;
}
request->read_index += read_or_error;
request->last_data_time = now;
if (now - start >= READ_BUDGET_MS)
return false;
}
// As before, on_response still fires, with the part that fitted
if (!request->container->is_read_complete()) {
ESP_LOGW(TAG, "Response body is larger than max_response_buffer_size; kept the first %zu bytes", request->buf_size);
}
return true;
}
void HttpRequestComponent::finish_(PendingRequest *request) {
request->finished = true;
// The body was read over several passes, so time it from the start, as a blocking read would
if (request->reading && request->container != nullptr)
request->container->duration_ms = millis() - request->start_time;
request->handler(request, RequestEvent::REQUEST_EVENT_FINISH);
}
void HttpRequestComponent::remove_dead_() {
PendingRequest **link = &this->requests_;
while (PendingRequest *request = *link) {
if (!request->finished && !request->cancelled) {
link = &request->next;
continue;
}
*link = request->next;
if (request->container != nullptr)
request->container->end();
request->free_buffer();
request->handler(request, RequestEvent::REQUEST_EVENT_DESTROY);
}
}
} // namespace esphome::http_request
+162 -52
View File
@@ -12,6 +12,7 @@
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -186,6 +187,13 @@ enum class HttpReadLoopResult : uint8_t {
TIMEOUT, ///< Timeout waiting for data, caller should exit loop
};
/// Progress of a request started with HttpRequestComponent::begin()
enum class HttpRequestProgress : uint8_t {
HTTP_REQUEST_PROGRESS_PENDING, ///< Still connecting, sending, or waiting for the response headers
HTTP_REQUEST_PROGRESS_DONE, ///< Response headers received; status_code is set and the body can be read
HTTP_REQUEST_PROGRESS_FAILED, ///< No response; the container must still be ended
};
/// Process a read result with timeout tracking and delay handling
/// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error
/// @param last_data_time Time of last successful read, updated when data received
@@ -249,8 +257,15 @@ class HttpContainer : public Parented<HttpRequestComponent> {
* - http_read_fully(): for simple "read N bytes" operations
*/
virtual int read(uint8_t *buf, size_t max_len) = 0;
/// Like read(), but returns 0 at once when no data has arrived yet, where read() may wait for it.
virtual int read_available(uint8_t *buf, size_t max_len) { return this->read(buf, max_len); }
virtual void end() = 0;
/// Advance a request started with HttpRequestComponent::begin(); call from the main loop until it is no
/// longer pending. Containers from start() are already done. Most calls return quickly, but on ESP-IDF the
/// first call for each host waits for the name lookup and the TCP connection, up to the whole timeout.
virtual HttpRequestProgress poll() { return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE; }
void set_secure(bool secure) { this->secure_ = secure; }
void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; }
uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; }
@@ -332,10 +347,56 @@ class HttpRequestResponseTrigger final : public Trigger<std::shared_ptr<HttpCont
}
};
/// What HttpRequestComponent asks of the action that owns a request
enum class RequestEvent : uint8_t {
REQUEST_EVENT_START, ///< Build the request and call begin()
REQUEST_EVENT_FINISH, ///< Fire the triggers and continue the action chain
REQUEST_EVENT_DESTROY, ///< Free the request
REQUEST_EVENT_ABANDON, ///< Dropped at shutdown: stop the action, so that it can run again
};
/// A request from an HttpRequestSendAction, run from HttpRequestComponent::loop().
/// The action derives from it to add the trigger arguments.
struct PendingRequest {
using Handler = void (*)(PendingRequest *request, RequestEvent event);
PendingRequest(Handler handler, void *owner) : handler(handler), owner(owner) {}
PendingRequest(const PendingRequest &) = delete;
PendingRequest &operator=(const PendingRequest &) = delete;
void free_buffer();
PendingRequest *next{nullptr};
Handler handler;
// The action, so that a stop of it can find its requests
void *owner;
// nullptr until the request has started
std::shared_ptr<HttpContainer> container;
uint8_t *buf{nullptr};
size_t buf_size{0};
size_t read_index{0};
uint32_t last_data_time{0};
uint32_t timeout{0};
uint32_t start_time{0};
bool capture{false};
bool reading{false};
bool failed{false};
bool finished{false};
bool cancelled{false};
};
class HttpRequestComponent : public Component {
public:
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void setup() override;
void loop() override;
void on_safe_shutdown() override;
void on_shutdown() override;
/// For HttpRequestSendAction: queue a request and take ownership of it. Requests start in order, up to
/// max_concurrent_requests at a time; one that can start at once is started here.
void submit(PendingRequest *request);
/// For HttpRequestSendAction: drop the requests of a stopped action without firing their triggers.
void cancel(void *owner);
void set_useragent(const char *useragent) { this->useragent_ = useragent; }
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
@@ -349,6 +410,7 @@ class HttpRequestComponent : public Component {
}
void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; }
void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; }
void set_max_concurrent_requests(uint8_t max_concurrent) { this->max_concurrent_ = max_concurrent; }
std::shared_ptr<HttpContainer> get(const char *url) { return this->start(url, "GET", "", std::vector<Header>{}); }
std::shared_ptr<HttpContainer> get(const char *url, const std::vector<Header> &request_headers) {
@@ -491,16 +553,44 @@ class HttpRequestComponent : public Component {
return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers);
}
/// Start a request without waiting for the response, then poll() the container until it is not pending.
/// Returns nullptr when the request cannot be started. Platforms without a non-blocking client complete
/// the request here.
virtual std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
return this->perform(url, method, body, request_headers, lower_case_collect_headers, timeout_ms);
}
protected:
virtual std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) = 0;
/// Longest time one loop() pass spends reading a response body
static constexpr uint32_t READ_BUDGET_MS = 10;
/// Requests that wait for a free slot; more are dropped with on_error
static constexpr uint8_t MAX_QUEUED = 4;
bool process_();
void drain_();
void begin_request_(PendingRequest *request);
bool advance_(PendingRequest *request);
void finish_(PendingRequest *request);
void remove_dead_();
const char *useragent_{nullptr};
bool follow_redirects_{};
uint16_t redirect_limit_{};
uint32_t timeout_{4500};
uint32_t watchdog_timeout_{0};
// Oldest first; the first max_concurrent_ live requests run
PendingRequest *requests_{nullptr};
// Depth of calls that hold request pointers; finished and cancelled requests are freed when it returns to 0
uint8_t busy_{0};
uint8_t max_concurrent_{1};
bool shutting_down_{false};
};
template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...> {
@@ -542,7 +632,42 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
this->max_response_buffer_size_ = max_response_buffer_size;
}
void play(const Ts &...x) override {
void play_complex(const Ts &...x) override {
this->num_running_++;
this->parent_->submit(new Request(this, x...)); // NOLINT(cppcoreguidelines-owning-memory)
}
void play(const Ts &...x) override { /* see play_complex */ }
protected:
struct Request final : PendingRequest {
Request(HttpRequestSendAction *action, const Ts &...x)
: PendingRequest(&HttpRequestSendAction::handle_event, action), args(x...) {}
std::tuple<stored_trigger_arg_t<Ts>...> args;
};
static void handle_event(PendingRequest *request, RequestEvent event) {
auto *self = static_cast<Request *>(request);
auto *action = static_cast<HttpRequestSendAction *>(request->owner);
switch (event) {
case RequestEvent::REQUEST_EVENT_START:
std::apply([action, self](auto &...x) { action->start_request_(*self, x...); }, self->args);
break;
case RequestEvent::REQUEST_EVENT_FINISH:
action->finish_(*self);
break;
case RequestEvent::REQUEST_EVENT_DESTROY:
delete self; // NOLINT(cppcoreguidelines-owning-memory)
break;
case RequestEvent::REQUEST_EVENT_ABANDON:
action->stop_complex();
break;
}
}
void stop() override { this->parent_->cancel(this); }
void start_request_(Request &request, const Ts &...x) {
std::string body;
if (this->body_.has_value()) {
body = this->body_.value(x...);
@@ -559,62 +684,47 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
request_headers.push_back({header.name, header.value(x...)});
}
const uint32_t timeout = this->timeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout();
auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body,
request_headers, this->lower_case_collect_headers_, timeout);
auto captured_args = std::make_tuple(x...);
if (container == nullptr) {
std::apply([this](Ts... captured_args_inner) { this->error_trigger_.trigger(captured_args_inner...); },
captured_args);
return;
}
request.timeout = this->timeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout();
#ifdef USE_HTTP_REQUEST_RESPONSE
if (this->capture_response_.value(x...)) {
size_t max_length = this->max_response_buffer_size_;
std::string response_body;
RAMAllocator<uint8_t> allocator;
uint8_t *buf = allocator.allocate(max_length);
if (buf != nullptr) {
// NOTE: HttpContainer::read() has non-BSD socket semantics - see top of this file
// Use http_read_loop_result() helper instead of checking return values directly
size_t read_index = 0;
uint32_t last_data_time = millis();
const uint32_t read_timeout = timeout;
while (container->get_bytes_read() < max_length) {
int read_or_error = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512));
App.feed_wdt();
yield();
auto result =
http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
if (result == HttpReadLoopResult::RETRY)
continue;
if (result != HttpReadLoopResult::DATA)
break; // COMPLETE, ERROR, or TIMEOUT
read_index += read_or_error;
}
response_body.reserve(read_index);
response_body.assign((char *) buf, read_index);
allocator.deallocate(buf, max_length);
}
std::apply(
[this, &container, &response_body](Ts... captured_args_inner) {
this->success_trigger_with_response_.trigger(container, response_body, captured_args_inner...);
},
captured_args);
} else
request.capture = this->capture_response_.value(x...);
#endif
{
std::apply([this, &container](
Ts... captured_args_inner) { this->success_trigger_.trigger(container, captured_args_inner...); },
captured_args);
request.buf_size = this->max_response_buffer_size_;
request.container = this->parent_->begin(this->url_.value(x...).c_str(), this->method_.value(x...), std::move(body),
request_headers, this->lower_case_collect_headers_, request.timeout);
}
void finish_(Request &request) {
auto &container = request.container;
if (request.failed) {
std::apply([this](auto &...args) { this->error_trigger_.trigger(args...); }, request.args);
}
container->end();
#ifdef USE_HTTP_REQUEST_RESPONSE
else if (request.capture) {
std::string response_body;
if (request.buf != nullptr)
response_body.assign(reinterpret_cast<char *>(request.buf), request.read_index);
request.free_buffer();
std::apply(
[this, &container, &response_body](auto &...args) {
this->success_trigger_with_response_.trigger(container, response_body, args...);
},
request.args);
}
#endif
else {
std::apply([this, &container](auto &...args) { this->success_trigger_.trigger(container, args...); },
request.args);
}
// End it before the chain can start another request, so that one client is held at a time
if (container != nullptr) {
container->end();
container.reset();
}
// A stop from the triggers ended this run; a run started after it continues the chain instead
if (!request.cancelled)
std::apply([this](auto &...args) { this->play_next_(args...); }, request.args);
}
protected:
void encode_json_(Ts... x, JsonObject root) {
for (const auto &item : this->json_) {
auto val = item.second;
@@ -2,8 +2,13 @@
#ifdef USE_ESP32
#include <algorithm>
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <sys/select.h>
#include "esphome/components/network/util.h"
#include "esphome/components/watchdog/watchdog.h"
@@ -20,6 +25,10 @@
namespace esphome::http_request {
ESPHOME_LOG_TAG(TAG, "http_request");
// Request body sent per esp_http_client_write() call, and the time one poll() spends sending it
static constexpr size_t SEND_CHUNK_SIZE = 1024;
static constexpr uint32_t SEND_BUDGET_MS = 10;
static constexpr uint32_t ERROR_DURATION_MS = 1000;
void HttpRequestIDF::dump_config() {
@@ -51,10 +60,9 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
return ESP_OK;
}
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
std::shared_ptr<HttpContainerIDF> HttpRequestIDF::create_container_(
const char *url, const char *method, const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers, uint32_t timeout_ms, [[maybe_unused]] bool async) {
if (!network::is_connected()) {
this->status_momentary_error(ERROR_DURATION_MS);
ESP_LOGE(TAG, "HTTP Request failed; Not connected to network");
@@ -106,11 +114,12 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
config.buffer_size = this->buffer_size_rx_;
config.buffer_size_tx = this->buffer_size_tx_;
const uint32_t start = millis();
const uint32_t watchdog_timeout = this->get_watchdog_timeout(timeout_ms);
watchdog::WatchdogManager wdm(watchdog_timeout);
config.event_handler = http_event_handler;
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
// Only TLS has a non-blocking connect worth having: the handshake. A plain connection waits for
// the TCP connection either way.
config.is_async = async && secure;
#endif
esp_http_client_handle_t client = esp_http_client_init(&config);
if (client == nullptr) {
@@ -121,7 +130,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
std::shared_ptr<HttpContainerIDF> container = std::make_shared<HttpContainerIDF>(client);
container->set_parent(this);
container->set_watchdog_timeout(watchdog_timeout);
container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms));
container->timeout_ms_ = timeout_ms;
container->async_ = config.is_async;
container->set_secure(secure);
@@ -132,101 +143,229 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
esp_http_client_set_header(client, header.name.c_str(), header.value.c_str());
}
const int body_len = body.length();
return container;
}
esp_err_t err = esp_http_client_open(client, body_len);
if (err != ESP_OK) {
this->status_momentary_error(ERROR_DURATION_MS);
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
esp_http_client_cleanup(client);
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
std::shared_ptr<HttpContainerIDF> container = this->start_container_(url, method, std::string(body), request_headers,
lower_case_collect_headers, timeout_ms, false);
if (container == nullptr) {
return nullptr;
}
// The same stages as begin(), run here until the response headers are in
watchdog::WatchdogManager wdm(container->get_watchdog_timeout());
HttpRequestProgress progress;
while ((progress = container->poll()) == HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING) {
container->feed_wdt();
delay(1);
}
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED) {
container->end();
return nullptr;
}
return container;
}
if (body_len > 0) {
int write_left = body_len;
int write_index = 0;
const char *buf = body.c_str();
while (write_left > 0) {
int written = esp_http_client_write(client, buf + write_index, write_left);
if (written <= 0) {
err = ESP_FAIL;
std::shared_ptr<HttpContainer> HttpRequestIDF::begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) {
return this->start_container_(url, method, std::move(body), request_headers, lower_case_collect_headers, timeout_ms,
true);
}
std::shared_ptr<HttpContainerIDF> HttpRequestIDF::start_container_(
const char *url, const char *method, std::string &&body, const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers, uint32_t timeout_ms, bool async) {
std::shared_ptr<HttpContainerIDF> container =
this->create_container_(url, method, request_headers, lower_case_collect_headers, timeout_ms, async);
if (container == nullptr) {
return nullptr;
}
container->body_ = std::move(body);
container->start_time_ = millis();
container->progress_time_ = container->start_time_;
container->redirects_left_ = this->follow_redirects_ ? this->redirect_limit_ : 0;
container->stage_ = HttpContainerIDF::Stage::STAGE_CONNECT;
return container;
}
HttpRequestIDF *HttpContainerIDF::idf_parent_() { return static_cast<HttpRequestIDF *>(this->parent_); }
HttpRequestProgress HttpContainerIDF::fail_(esp_err_t err) {
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
this->parent_->status_momentary_error(ERROR_DURATION_MS);
this->stage_ = Stage::STAGE_FAILED;
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
}
HttpRequestProgress HttpContainerIDF::wait_() {
// millis(), not the loop time: progress_time_ is set after calls that block within this pass
if (millis() - this->progress_time_ >= this->timeout_ms_) {
return this->fail_(ESP_ERR_TIMEOUT);
}
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
bool HttpContainerIDF::socket_ready_(bool write) {
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
const int fd = esp_http_client_get_socket(this->client_);
if (fd < 0) {
return true;
}
fd_set fds;
FD_ZERO(&fds);
FD_SET(fd, &fds);
timeval no_wait{};
// An error also counts as ready, so that the call that follows reports it
// Qualified: esphome::select is a namespace
return ::select(fd + 1, write ? nullptr : &fds, write ? &fds : nullptr, nullptr, &no_wait) != 0;
#else
return true;
#endif
}
// Each stage either finishes, or returns PENDING after a call that did not wait. The waits left are
// the name lookup and TCP connection in the first esp_http_client_open() of each hop: a non-blocking
// esp-tls connect that times out once never completes, as select() clears the sets it keeps.
HttpRequestProgress HttpContainerIDF::poll() {
while (true) {
switch (this->stage_) {
case Stage::STAGE_DONE:
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE;
case Stage::STAGE_FAILED:
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
case Stage::STAGE_CONNECT: {
// Only the first call of a hop waits
watchdog::WatchdogManager wdm(this->connecting_ ? 0 : this->get_watchdog_timeout());
esp_http_client_set_timeout_ms(this->client_, this->connecting_ ? 1 : this->timeout_ms_);
this->feed_wdt();
esp_err_t err = esp_http_client_open(this->client_, this->body_.size());
this->feed_wdt();
if (err == ESP_ERR_HTTP_EAGAIN) {
this->connecting_ = true;
return this->wait_();
}
this->connecting_ = false;
if (err != ESP_OK) {
return this->fail_(err);
}
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
if (this->async_) {
// Back to a blocking socket, so that mbedtls waits for the rest of a record instead of
// failing; reads only start once the socket is readable
const int fd = esp_http_client_get_socket(this->client_);
const int flags = fd < 0 ? -1 : fcntl(fd, F_GETFL, 0);
if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
ESP_LOGE(TAG, "Could not make the socket blocking: fd %d, errno %d", fd, errno);
return this->fail_(ESP_FAIL);
}
}
#endif
esp_http_client_set_timeout_ms(this->client_, this->timeout_ms_);
this->progress_time_ = millis();
this->stage_ = this->body_sent_ < this->body_.size() ? Stage::STAGE_SEND_BODY : Stage::STAGE_HEADERS;
break;
}
write_left -= written;
write_index += written;
container->feed_wdt();
}
}
if (err != ESP_OK) {
this->status_momentary_error(ERROR_DURATION_MS);
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
esp_http_client_cleanup(client);
return nullptr;
}
container->feed_wdt();
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header).
// The read() method handles content_length == 0 specially to support chunked responses.
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
container->status_code = esp_http_client_get_status_code(client);
container->feed_wdt();
container->duration_ms = millis() - start;
if (is_success(container->status_code)) {
return container;
}
if (this->follow_redirects_) {
auto num_redirects = this->redirect_limit_;
while (is_redirect(container->status_code) && num_redirects > 0) {
err = esp_http_client_set_redirection(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err));
this->status_momentary_error(ERROR_DURATION_MS);
esp_http_client_cleanup(client);
return nullptr;
case Stage::STAGE_SEND_BODY: {
// esp_http_client_write() returns only once all it was given is sent, so give it a piece at a time
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
const uint32_t start = millis();
while (this->body_sent_ < this->body_.size()) {
if (!this->socket_ready_(true)) {
return this->wait_();
}
const size_t len = std::min(this->body_.size() - this->body_sent_, SEND_CHUNK_SIZE);
int written = esp_http_client_write(this->client_, this->body_.data() + this->body_sent_, len);
this->feed_wdt();
if (written < 0) {
return this->fail_(ESP_FAIL);
}
if (written == 0) {
return this->wait_();
}
this->body_sent_ += written;
this->progress_time_ = millis();
if (this->progress_time_ - start >= SEND_BUDGET_MS && this->body_sent_ < this->body_.size()) {
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
}
this->body_ = std::string();
this->stage_ = Stage::STAGE_HEADERS;
break;
}
case Stage::STAGE_HEADERS: {
if (!this->socket_ready_(false)) {
if (millis() - this->progress_time_ < this->timeout_ms_) {
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
}
// As perform(), where esp_http_client_fetch_headers() times out: no response is status -1
return this->response_failed_(-1);
}
// The response has begun to arrive, so this waits only for the rest of the headers, or over TLS
// the rest of the record that holds them
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
this->feed_wdt();
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header)
const int64_t length = esp_http_client_fetch_headers(this->client_);
this->feed_wdt();
this->duration_ms = millis() - this->start_time_;
if (length < 0) {
return this->response_failed_(-1);
}
this->content_length = length;
this->set_chunked(esp_http_client_is_chunked_response(this->client_));
this->status_code = esp_http_client_get_status_code(this->client_);
if (is_success(this->status_code)) {
this->stage_ = Stage::STAGE_DONE;
break;
}
if (this->redirects_left_ > 0 && is_redirect(this->status_code)) {
esp_err_t err = esp_http_client_set_redirection(this->client_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err));
return this->fail_(err);
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char redirect_url[256]{};
if (esp_http_client_get_url(client, redirect_url, sizeof(redirect_url) - 1) == ESP_OK) {
ESP_LOGV(TAG, "redirecting to url: %s", redirect_url);
}
char redirect_url[256]{};
if (esp_http_client_get_url(this->client_, redirect_url, sizeof(redirect_url) - 1) == ESP_OK) {
ESP_LOGV(TAG, "redirecting to url: %s", redirect_url);
}
#endif
err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err));
this->status_momentary_error(ERROR_DURATION_MS);
esp_http_client_cleanup(client);
return nullptr;
this->redirects_left_--;
// Collect only the final response's headers; redirects do not resend the body
this->response_headers_.clear();
this->body_ = std::string();
this->body_sent_ = 0;
this->progress_time_ = millis();
this->stage_ = Stage::STAGE_CONNECT;
break;
}
if (is_redirect(this->status_code) && this->idf_parent_()->follow_redirects_) {
ESP_LOGW(TAG, "Reach redirect limit count=%d", this->idf_parent_()->redirect_limit_);
}
return this->response_failed_(this->status_code);
}
container->feed_wdt();
// IDF is the only backend reusing the container across redirect hops;
// drop the previous hop's headers (Arduino/host collect only the final response)
container->response_headers_.clear();
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
container->status_code = esp_http_client_get_status_code(client);
container->feed_wdt();
container->duration_ms = millis() - start;
if (is_success(container->status_code)) {
return container;
}
num_redirects--;
}
if (num_redirects == 0) {
ESP_LOGW(TAG, "Reach redirect limit count=%d", this->redirect_limit_);
}
}
}
ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code);
this->status_momentary_error(ERROR_DURATION_MS);
return container;
HttpRequestProgress HttpContainerIDF::response_failed_(int status_code) {
this->status_code = status_code;
this->duration_ms = millis() - this->start_time_;
char url[256]{};
esp_http_client_get_url(this->client_, url, sizeof(url) - 1);
ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, this->status_code);
this->parent_->status_momentary_error(ERROR_DURATION_MS);
this->stage_ = Stage::STAGE_DONE;
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE;
}
bool HttpContainerIDF::is_read_complete() const {
@@ -265,8 +404,21 @@ bool HttpContainerIDF::is_read_complete() const {
// after its internal transport timeout (configured via timeout_ms) expires.
// This is passed through as a negative return value, which callers treat as an error.
int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
esp_http_client_set_timeout_ms(this->client_, this->timeout_ms_);
return this->read_(buf, max_len);
}
int HttpContainerIDF::read_available(uint8_t *buf, size_t max_len) {
// A zero timeout only polls; TLS still waits for the rest of a record that has begun to arrive
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
esp_http_client_set_timeout_ms(this->client_, 0);
int read_len_or_error = this->read_(buf, max_len);
return read_len_or_error == -ESP_ERR_HTTP_EAGAIN ? 0 : read_len_or_error;
}
int HttpContainerIDF::read_(uint8_t *buf, size_t max_len) {
const uint32_t start = millis();
// Check if we've already read all expected content (non-chunked and no-body only).
// Use the base class check here, NOT the override: esp_http_client_is_complete_data_received()
@@ -5,30 +5,71 @@
#ifdef USE_ESP32
#include <esp_event.h>
#include <esp_idf_version.h>
#include <esp_http_client.h>
#include <esp_netif.h>
#include <esp_tls.h>
// The socket is needed to wait for the response without blocking; esp_http_client_get_socket() is new in 5.5.3
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 3)
#define USE_HTTP_REQUEST_IDF_SOCKET_POLL
#endif
namespace esphome::http_request {
class HttpRequestIDF;
class HttpContainerIDF : public HttpContainer {
public:
HttpContainerIDF(esp_http_client_handle_t client) : client_(client) {}
int read(uint8_t *buf, size_t max_len) override;
int read_available(uint8_t *buf, size_t max_len) override;
void end() override;
bool is_read_complete() const override;
HttpRequestProgress poll() override;
/// @brief Feeds the watchdog timer if the executing task has one attached
void feed_wdt();
protected:
friend class HttpRequestIDF;
enum class Stage : uint8_t {
STAGE_CONNECT,
STAGE_SEND_BODY,
STAGE_HEADERS,
STAGE_DONE,
STAGE_FAILED,
};
int read_(uint8_t *buf, size_t max_len);
/// True when the socket can be read or written without waiting; always true when it cannot be checked
bool socket_ready_(bool write);
HttpRequestProgress wait_();
HttpRequestProgress fail_(esp_err_t err);
/// Finish with an unsuccessful status, which is reported through on_response like a success
HttpRequestProgress response_failed_(int status_code);
HttpRequestIDF *idf_parent_();
esp_http_client_handle_t client_;
// Owned copy (not a reference): must outlive perform() for the response-header event handler
std::vector<std::string> collect_headers_;
// Request body for begin(), released once sent
std::string body_;
size_t body_sent_{0};
uint32_t timeout_ms_{0};
// Time of the last step forward, for the request timeout of begin()
uint32_t progress_time_{0};
uint32_t start_time_{0};
uint16_t redirects_left_{0};
Stage stage_{Stage::STAGE_DONE};
// An esp_http_client_open() of a non-blocking client returned ESP_ERR_HTTP_EAGAIN
bool connecting_{false};
bool async_{false};
};
class HttpRequestIDF final : public HttpRequestComponent {
friend class HttpContainerIDF;
public:
// User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero.
HttpRequestIDF() {}
@@ -40,7 +81,21 @@ class HttpRequestIDF final : public HttpRequestComponent {
void set_verify_ssl(bool verify_ssl) { this->verify_ssl_ = verify_ssl; }
void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; }
std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms) override;
protected:
/// A container that poll() takes from the connect through to the response headers
std::shared_ptr<HttpContainerIDF> start_container_(const char *url, const char *method, std::string &&body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms, bool async);
std::shared_ptr<HttpContainerIDF> create_container_(const char *url, const char *method,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
uint32_t timeout_ms, bool async);
std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
const std::vector<Header> &request_headers,
const std::vector<std::string> &lower_case_collect_headers,
-1
View File
@@ -729,7 +729,6 @@ CONF_DISP_BG_COLOR = "disp_bg_color"
CONF_DISP_BG_IMAGE = "disp_bg_image"
CONF_DISP_BG_OPA = "disp_bg_opa"
CONF_BODY = "body"
CONF_BUTTONS = "buttons"
CONF_CHANGE_RATE = "change_rate"
CONF_CLICKABLE = "clickable"
CONF_CLOSE_BUTTON = "close_button"
@@ -1,6 +1,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_ROWS
from esphome.components.const import CONF_BUTTONS, CONF_ROWS
from esphome.components.key_provider import KeyProvider
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH
@@ -10,7 +10,6 @@ from esphome.types import Expression
from ..automation import action_to_code
from ..defines import (
BUTTONMATRIX_CTRLS,
CONF_BUTTONS,
CONF_CONTROL,
CONF_KEY_CODE,
CONF_MAIN,
+1 -1
View File
@@ -1,4 +1,5 @@
from esphome import codegen as cg, config_validation as cv
from esphome.components.const import CONF_BUTTONS
from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT
from esphome.core import ID
from esphome.cpp_generator import MockObjClass
@@ -7,7 +8,6 @@ from esphome.cpp_types import nullptr
from ..defines import (
CONF_BODY,
CONF_BUTTON_STYLE,
CONF_BUTTONS,
CONF_CLOSE_BUTTON,
CONF_HEADER_BUTTONS,
CONF_MAIN,
+7
View File
@@ -18,6 +18,7 @@ CONF_ON_USSD_RECEIVED = "on_ussd_received"
CONF_ON_INCOMING_CALL = "on_incoming_call"
CONF_ON_CALL_CONNECTED = "on_call_connected"
CONF_ON_CALL_DISCONNECTED = "on_call_disconnected"
CONF_ON_DTMF_RECEIVED = "on_dtmf_received"
CONF_RECIPIENT = "recipient"
CONF_USSD = "ussd"
@@ -30,6 +31,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CALL_CONNECTED): automation.validate_automation({}),
cv.Optional(CONF_ON_CALL_DISCONNECTED): automation.validate_automation({}),
cv.Optional(CONF_ON_USSD_RECEIVED): automation.validate_automation({}),
cv.Optional(CONF_ON_DTMF_RECEIVED): automation.validate_automation({}),
}
)
.extend(cv.polling_component_schema("5s"))
@@ -62,6 +64,11 @@ _CALLBACK_AUTOMATIONS = (
"add_on_ussd_received_callback",
[(cg.std_string, "ussd")],
),
automation.CallbackAutomation(
CONF_ON_DTMF_RECEIVED,
"add_on_dtmf_received_callback",
[(cg.std_string, "key")],
),
)
+30 -1
View File
@@ -14,6 +14,8 @@ void Sim800LComponent::update() {
if (this->watch_dog_++ == 2) {
this->state_ = STATE_INIT;
this->write(26);
// The module may have restarted, enable DTMF detection again
this->dtmf_ready_ = false;
}
if (this->expect_ack_)
@@ -82,6 +84,15 @@ void Sim800LComponent::parse_cmd_(std::string message) {
this->call_state_ = 6;
this->call_disconnected_callback_.call();
}
} else if (message.starts_with("+DTMF:")) {
// Unsolicited DTMF key report: "+DTMF: <key>[,<duration>]"
size_t start = message.find_first_not_of(' ', 6);
if (start != std::string::npos) {
std::string key = message.substr(start, message.find(',', start) - start);
ESP_LOGD(TAG, "DTMF received: %s", key.c_str());
this->dtmf_received_callback_.call(key);
}
return;
}
}
@@ -93,6 +104,14 @@ void Sim800LComponent::parse_cmd_(std::string message) {
// Expected ack but AT echo received
this->state_ = STATE_DISABLE_ECHO;
this->expect_ack_ = true;
} else if (this->state_ == STATE_DDET_SENT) {
if (message != "ERROR" && !message.starts_with("+CME ERROR:")) {
// Unrelated unsolicited line, keep waiting for the DDET response
this->expect_ack_ = true;
return;
}
// Module firmware without DTMF support, carry on with the setup sequence
ESP_LOGW(TAG, "DTMF detection not supported by the module: %s", message.c_str());
} else {
ESP_LOGW(TAG, "Not ack. %d %s", this->state_, message.c_str());
this->state_ = STATE_IDLE; // Let it timeout
@@ -145,7 +164,12 @@ void Sim800LComponent::parse_cmd_(std::string message) {
break;
case STATE_SETUP_CLIP:
send_cmd_("AT+CLIP=1");
this->state_ = STATE_CREG;
this->state_ = this->dtmf_enabled_ && !this->dtmf_ready_ ? STATE_SETUP_DDET : STATE_CREG;
this->expect_ack_ = true;
break;
case STATE_SETUP_DDET:
send_cmd_("AT+DDET=1");
this->state_ = STATE_DDET_SENT;
this->expect_ack_ = true;
break;
case STATE_SETUP_USSD:
@@ -188,6 +212,11 @@ void Sim800LComponent::parse_cmd_(std::string message) {
if (message == "OK")
this->state_ = STATE_INIT;
break;
case STATE_DDET_SENT:
// Tried once per module reset; firmware without DDET only logs a warning, and a passing error is retried
// after the next reset
this->dtmf_ready_ = true;
[[fallthrough]];
case STATE_CREG:
send_cmd_("AT+CREG?");
this->state_ = STATE_CREG_WAIT;
+11 -1
View File
@@ -44,7 +44,9 @@ enum State {
STATE_SEND_USSD1,
STATE_SEND_USSD2,
STATE_CHECK_USSD,
STATE_RECEIVED_USSD
STATE_RECEIVED_USSD,
STATE_SETUP_DDET,
STATE_DDET_SENT
};
class Sim800LComponent final : public uart::UARTDevice, public PollingComponent {
@@ -76,6 +78,11 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent
template<typename F> void add_on_ussd_received_callback(F &&callback) {
this->ussd_received_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_dtmf_received_callback(F &&callback) {
// DTMF detection is only enabled on the module when someone listens for it
this->dtmf_enabled_ = true;
this->dtmf_received_callback_.add(std::forward<F>(callback));
}
void send_sms(const std::string &recipient, const std::string &message);
void send_ussd(const std::string &ussd_code);
void dial(const std::string &recipient);
@@ -111,6 +118,8 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent
bool connect_pending_{false};
bool disconnect_pending_{false};
bool send_ussd_pending_{false};
bool dtmf_enabled_{false};
bool dtmf_ready_{false};
uint8_t call_state_{6};
CallbackManager<void(std::string, std::string)> sms_received_callback_;
@@ -118,6 +127,7 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent
CallbackManager<void()> call_connected_callback_;
CallbackManager<void()> call_disconnected_callback_;
CallbackManager<void(std::string)> ussd_received_callback_;
CallbackManager<void(std::string)> dtmf_received_callback_;
};
} // namespace esphome::sim800l
+52
View File
@@ -4,6 +4,7 @@ from collections import Counter
import logging
import os
from pathlib import Path
import re
from esphome import automation, core
import esphome.codegen as cg
@@ -42,6 +43,7 @@ from esphome.const import (
CONF_PRIORITY,
CONF_PROJECT,
CONF_TRIGGER_ID,
CONF_URL,
CONF_VERSION,
CONF_WATCHDOG_TIMEOUT,
KEY_CORE,
@@ -68,8 +70,11 @@ from esphome.helpers import (
)
from esphome.types import ConfigType
CONF_CONTACT = "contact"
CONF_LOOP_INTERVAL = "loop_interval"
CONF_RELATION = "relation"
CONF_SBOM = "sbom"
CONF_SUPPLIER = "supplier"
CONF_SUSPEND_LOOP = "suspend_loop"
_LOGGER = logging.getLogger(__name__)
@@ -258,6 +263,52 @@ def valid_project_name(value: str):
return value
# The checks below match esp-idf-sbom's, which aborts on a value they reject
def _sbom_email(value: str) -> str:
value = cv.string_strict(value)
if re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value) is None:
raise cv.Invalid("Expected an email address")
return value
def _sbom_url(value: str) -> str:
value = cv.url(value)
if not value.startswith(("http://", "https://")):
raise cv.Invalid("Expected an http or https URL")
return value
def _sbom_supplier(value: str) -> str:
value = cv.string_strict(value).strip()
if not value:
raise cv.Invalid("Supplier name must not be empty")
if any(c in value for c in "()\n\r"):
raise cv.Invalid("Supplier name must not contain parentheses or line breaks")
return value
def _sbom_platform(value: ConfigType) -> ConfigType:
# Only ESP-IDF builds produce the manifests esp-idf-sbom reads
if not CORE.is_esp32:
raise cv.Invalid(
f"SBOM details are only used on ESP32, not on {CORE.target_platform}"
)
return value
SBOM_SCHEMA = cv.All(
_sbom_platform,
cv.Schema(
{
cv.Required(CONF_SUPPLIER): _sbom_supplier,
cv.Optional(CONF_CONTACT): _sbom_email,
cv.Optional(CONF_URL): _sbom_url,
}
),
cv.has_at_least_one_key(CONF_CONTACT, CONF_URL),
)
if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" in os.environ:
_compile_process_limit_default = min(
int(os.environ["ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT"]), get_usable_cpu_count()
@@ -413,6 +464,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_VERSION): cv.All(
cv.string_strict, cv.ByteLength(max=PROJECT_MAX_LENGTH)
),
cv.Optional(CONF_SBOM): SBOM_SCHEMA,
cv.Optional(CONF_ON_UPDATE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
+10 -5
View File
@@ -309,11 +309,16 @@ def _write_sbom_yml(component: IDFComponent) -> None:
target.unlink()
def _remove_generated_sbom_yml(target: Path) -> None:
"""Remove ``target`` if ESPHome wrote it, so a failed run leaves no stale version."""
def _remove_stale_sbom_yml(component: IDFComponent) -> None:
"""Remove the sbom.yml ESPHome wrote or copied, so a failed run leaves no stale one."""
target = component.path / "sbom.yml"
with contextlib.suppress(OSError, ValueError):
if target.read_text(encoding="utf-8").startswith(SBOM_YML_HEADER):
target.unlink()
# A local library's build dir holds only ESPHome's files; elsewhere the
# library may ship its own, which only the header tells apart
if component.source_dir.resolve() != component.path.resolve() or (
target.read_text(encoding="utf-8").startswith(SBOM_YML_HEADER)
):
target.unlink(missing_ok=True)
def _emit_idf_component(component: IDFComponent) -> None:
@@ -341,7 +346,7 @@ def _emit_idf_component(component: IDFComponent) -> None:
_LOGGER.warning(
"SBOM: could not write sbom.yml for %s: %s", component.name, err
)
_remove_generated_sbom_yml(component.path / "sbom.yml")
_remove_stale_sbom_yml(component)
def generate_idf_components(
+43 -13
View File
@@ -26,12 +26,14 @@ from esphome.const import (
CONF_ESPHOME,
CONF_NAME,
CONF_PROJECT,
CONF_URL,
CONF_VERSION,
__version__ as ESPHOME_VERSION,
)
from esphome.core import CORE
from esphome.core.config import CONF_CONTACT, CONF_SBOM, CONF_SUPPLIER
from esphome.git import GitException, run_git_command, strip_url_credentials
from esphome.helpers import write_file_if_changed
from esphome.helpers import get_bool_env, write_file_if_changed
_LOGGER = logging.getLogger(__name__)
@@ -99,16 +101,18 @@ def _https_repo_url(url: str) -> str:
def _checkout_remote(path: Path) -> tuple[str, str] | None:
"""Return the name and URL of the remote the branch tracks (else origin), if any."""
remote = "origin"
try:
branch = run_git_command(
["git", "rev-parse", "--abbrev-ref", "HEAD"], git_dir=path
)
remote = run_git_command(
["git", "config", "--get", f"branch.{branch}.remote"], git_dir=path
)
except GitException:
# Detached HEAD or a branch that tracks nothing
remote = "origin"
if branch != "HEAD": # HEAD means detached, which tracks nothing
remote = run_git_command(
["git", "config", "--get", f"branch.{branch}.remote"], git_dir=path
)
except GitException as err:
# Usually a branch that tracks nothing
_LOGGER.debug("SBOM: no tracked remote in %s, using origin: %s", path, err)
try:
return remote, run_git_command(
["git", "remote", "get-url", remote], git_dir=path
@@ -180,8 +184,10 @@ def _esphome_vcs() -> _Source | None:
)
except importlib.metadata.PackageNotFoundError:
return None
if not direct_url:
if direct_url is None:
return None
if not direct_url.strip():
return _unknown_install("empty direct_url.json")
# Not installed from a package index, so the release version alone may not
# describe it. The raw file is never logged: its URL can hold a token.
try:
@@ -195,16 +201,38 @@ def _esphome_vcs() -> _Source | None:
if commit:
return _Source(strip_url_credentials(url), commit)
if "dir_info" in info and url.startswith("file:"):
# An editable or local install may be a fork or a patched clone. The
# Docker image's tree has no .git, so it keeps the release description.
return _checkout_vcs(Path(url2pathname(urlparse(url).path)))
# An editable or local install may be a fork or a patched clone
if (
source := _checkout_vcs(Path(url2pathname(urlparse(url).path)))
) is not None:
return source
# The official image's tree has no .git and is the release it was built from
if get_bool_env("ESPHOME_IS_DOCKER_IMAGE"):
return None
return _unknown_install("local folder without git")
return _unknown_install("not a git or local folder install")
def _project_manifest() -> dict[str, Any] | None:
if (project := CORE.config[CONF_ESPHOME].get(CONF_PROJECT)) is None:
return None
return {"name": project[CONF_NAME], "version": project[CONF_VERSION]}
manifest: dict[str, Any] = {
"name": project[CONF_NAME],
"version": project[CONF_VERSION],
}
if (sbom := project.get(CONF_SBOM)) is None:
return manifest
organization = f"Organization: {sbom[CONF_SUPPLIER]}"
contact = sbom.get(CONF_CONTACT)
manifest["supplier"] = f"{organization} ({contact})" if contact else organization
# The manufacturer is the vulnerability contact for the SBOM document
manufacturer = {"name": organization}
if contact:
manufacturer["contact"] = contact
if url := sbom.get(CONF_URL):
manufacturer["url"] = url
manifest["document"] = {"manufacturer": manufacturer}
return manifest
def _source_commit(source: ExternalSource) -> str | None:
@@ -268,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",
@@ -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
+2 -2
View File
@@ -4,13 +4,13 @@ wifi:
time:
- platform: sntp
id: duco_sntp_time
id: sntp_time
duco:
id: duco_box
discovery: {}
time:
time_id: duco_sntp_time
time_id: sntp_time
text_sensor:
- platform: duco
@@ -3,3 +3,10 @@ packages:
esphome:
suspend_loop: true
project:
name: esphome.test
version: "1.1"
sbom:
supplier: ESPHome Test
contact: security@example.com
url: https://example.com/security
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# AUTO_LOAD sits on the climate platform, which the unit test build does not load.
manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"]
@@ -1,4 +1,11 @@
climate:
- platform: hitachi_ac344
id: hitachi_climate
name: Hitachi Climate
transmitter_id: xmitr
switch:
- platform: hitachi_ac344
hitachi_ac344_id: hitachi_climate
mildew_proof:
name: Hitachi Mildew Proof
@@ -0,0 +1,148 @@
#include <gtest/gtest.h>
#include <array>
#include "esphome/components/hitachi_ac344/hitachi_ac344.h"
namespace esphome::hitachi_ac344::testing {
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
const remote_base::RemoteTransmitData &data() const { return this->temp_; }
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override {}
};
class HitachiSwingTest : public ::testing::Test {
protected:
void SetUp() override {
this->climate_.set_transmitter(&this->transmitter_);
this->climate_.mode = climate::CLIMATE_MODE_COOL;
this->climate_.target_temperature = 24;
this->climate_.fan_mode = climate::CLIMATE_FAN_AUTO;
this->climate_.swing_mode = climate::CLIMATE_SWING_OFF;
}
// The byte the frame carries at the given position: the header takes two timings and every bit two more.
uint8_t frame_byte_(size_t index) const {
const auto &timings = this->transmitter_.data().get_data();
uint8_t value = 0;
for (uint8_t bit = 0; bit < 8; bit++) {
if (timings[2 + (index * 8 + bit) * 2 + 1] == -static_cast<int32_t>(HITACHI_AC344_ONE_SPACE)) {
value |= 1 << bit;
}
}
return value;
}
uint8_t button_after_swing_(climate::ClimateSwingMode mode) {
auto call = this->climate_.make_call();
call.set_swing_mode(mode);
call.perform();
return this->frame_byte_(HITACHI_AC344_BUTTON_BYTE);
}
// Feed the unit's own frame to the receiver: header, 43 bytes least significant bit first, footer mark.
void receive_(const std::array<uint8_t, HITACHI_AC344_STATE_LENGTH> &frame) {
remote_base::RawTimings timings{HITACHI_AC344_HDR_MARK, -static_cast<int32_t>(HITACHI_AC344_HDR_SPACE)};
for (uint8_t byte : frame) {
for (uint8_t bit = 0; bit < 8; bit++) {
timings.push_back(HITACHI_AC344_BIT_MARK);
timings.push_back(
-static_cast<int32_t>(byte & (1 << bit) ? HITACHI_AC344_ONE_SPACE : HITACHI_AC344_ZERO_SPACE));
}
}
timings.push_back(HITACHI_AC344_BIT_MARK);
remote_base::RemoteReceiveData data(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
ASSERT_TRUE(static_cast<remote_base::RemoteReceiverListener &>(this->climate_).on_receive(data));
}
HitachiClimate climate_;
CapturingTransmitter transmitter_;
};
TEST_F(HitachiSwingTest, VerticalSwingIsPressedOnlyWhenItChanges) {
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_SWINGV);
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_POWER);
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_BOTH), HITACHI_AC344_BUTTON_POWER);
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_HORIZONTAL), HITACHI_AC344_BUTTON_SWINGV);
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_OFF), HITACHI_AC344_BUTTON_POWER);
}
TEST_F(HitachiSwingTest, HorizontalSwingTravelsInTheFrameBits) {
this->button_after_swing_(climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_EQ(this->frame_byte_(HITACHI_AC344_SWINGH_BYTE) & 0x07, HITACHI_AC344_SWINGH_AUTO);
this->button_after_swing_(climate::CLIMATE_SWING_OFF);
EXPECT_EQ(this->frame_byte_(HITACHI_AC344_SWINGH_BYTE) & 0x07, HITACHI_AC344_SWINGH_MIDDLE);
}
TEST_F(HitachiSwingTest, ReceivedFrameResyncsTheVerticalSwingState) {
// A cooling frame from the remote with vertical swing on: power on, mode cool, swing bit set.
std::array<uint8_t, HITACHI_AC344_STATE_LENGTH> frame{};
frame[HITACHI_AC344_POWER_BYTE] = HITACHI_AC344_POWER_ON;
frame[HITACHI_AC344_MODE_BYTE] = HITACHI_AC344_MODE_COOL | (HITACHI_AC344_FAN_AUTO << 4);
frame[HITACHI_AC344_TEMP_BYTE] = 24 << HITACHI_AC344_TEMP_OFFSET;
frame[HITACHI_AC344_SWINGH_BYTE] = HITACHI_AC344_SWINGH_MIDDLE;
frame[HITACHI_AC344_SWINGV_BYTE] = 1 << HITACHI_AC344_SWINGV_OFFSET;
// An on_state automation that transmits from the publish inside on_receive must already see the received frame,
// so its temperature change goes out as a power press and not another swing press.
bool transmitted = false;
uint8_t button_from_callback = 0;
this->climate_.add_on_state_callback([&](climate::Climate &) {
if (transmitted)
return;
transmitted = true;
auto call = this->climate_.make_call();
call.set_target_temperature(22);
call.perform();
button_from_callback = this->frame_byte_(HITACHI_AC344_BUTTON_BYTE);
});
this->receive_(frame);
EXPECT_EQ(this->climate_.swing_mode, climate::CLIMATE_SWING_VERTICAL);
EXPECT_TRUE(transmitted);
EXPECT_EQ(button_from_callback, HITACHI_AC344_BUTTON_POWER);
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_OFF), HITACHI_AC344_BUTTON_SWINGV);
}
TEST_F(HitachiSwingTest, MildewProofSendsItsOwnButtonAndKeepsTheSwingState) {
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_SWINGV);
this->climate_.set_mildew_proof(true);
EXPECT_EQ(this->frame_byte_(HITACHI_AC344_BUTTON_BYTE), HITACHI_AC344_BUTTON_MILDEWPROOF);
EXPECT_TRUE(this->climate_.get_mildew_proof());
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_POWER);
}
TEST_F(HitachiSwingTest, QuietFanUsesTheMinimumSpeedBothWays) {
auto call = this->climate_.make_call();
call.set_fan_mode(climate::CLIMATE_FAN_QUIET);
call.perform();
EXPECT_EQ(this->frame_byte_(HITACHI_AC344_FAN_BYTE) >> 4, HITACHI_AC344_FAN_MIN);
std::array<uint8_t, HITACHI_AC344_STATE_LENGTH> frame{};
frame[HITACHI_AC344_POWER_BYTE] = HITACHI_AC344_POWER_ON;
frame[HITACHI_AC344_MODE_BYTE] = HITACHI_AC344_MODE_COOL | (HITACHI_AC344_FAN_HIGH << 4);
frame[HITACHI_AC344_TEMP_BYTE] = 24 << HITACHI_AC344_TEMP_OFFSET;
frame[HITACHI_AC344_SWINGH_BYTE] = HITACHI_AC344_SWINGH_MIDDLE;
this->receive_(frame);
EXPECT_EQ(this->climate_.fan_mode, climate::CLIMATE_FAN_HIGH);
frame[HITACHI_AC344_MODE_BYTE] = HITACHI_AC344_MODE_COOL | (HITACHI_AC344_FAN_MIN << 4);
this->receive_(frame);
EXPECT_EQ(this->climate_.fan_mode, climate::CLIMATE_FAN_QUIET);
}
TEST_F(HitachiSwingTest, RestoredVerticalSwingSeedsTheTrackedState) {
static_cast<Component &>(this->climate_).setup();
EXPECT_EQ(this->button_after_swing_(climate::CLIMATE_SWING_VERTICAL), HITACHI_AC344_BUTTON_SWINGV);
// After a reboot the unit still swings vertically, so a temperature change must not press the swing button.
HitachiClimate rebooted;
rebooted.set_transmitter(&this->transmitter_);
static_cast<Component &>(rebooted).setup();
EXPECT_EQ(rebooted.swing_mode, climate::CLIMATE_SWING_VERTICAL);
auto call = rebooted.make_call();
call.set_target_temperature(22);
call.perform();
EXPECT_EQ(this->frame_byte_(HITACHI_AC344_BUTTON_BYTE), HITACHI_AC344_BUTTON_POWER);
}
} // namespace esphome::hitachi_ac344::testing
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The send action tests read the response body, which the real to_code enables for
# configs that capture it.
async def to_code_testing(config):
cg.add_define("USE_HTTP_REQUEST_RESPONSE")
manifest.to_code = to_code_testing
@@ -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
+4
View File
@@ -29,6 +29,10 @@ sim800l:
- sim800l.disconnect
on_ussd_received:
- logger.log: "ussd_received"
on_dtmf_received:
- logger.log:
format: "DTMF key '%s'"
args: ["key.c_str()"]
on_call_connected:
- logger.log: "call connected"
on_call_disconnected:
+59
View File
@@ -31,6 +31,7 @@ from esphome.core.config import (
CONF_RELATION,
CONF_SUSPEND_LOOP,
DEVICE_RELATIONS,
SBOM_SCHEMA,
SHARED_DEVICE_RELATION_DEFINES,
Area,
make_app_name_cpp,
@@ -797,6 +798,64 @@ def test_includes_in_a_package_resolve_next_to_the_package(
assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")]
@pytest.fixture(name="esp32_target")
def fixture_esp32_target() -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
@pytest.mark.usefixtures("esp32_target")
@pytest.mark.parametrize(
"value",
[
{"supplier": "Acme Corp", "contact": "psirt@acme.example"},
{"supplier": "Acme Corp", "url": "https://acme.example/security"},
{
"supplier": "Acme Corp",
"contact": "psirt@acme.example",
"url": "http://acme.example",
},
],
)
def test_sbom_schema_valid(value: dict[str, str]) -> None:
assert SBOM_SCHEMA(value) == value
@pytest.mark.usefixtures("esp32_target")
def test_sbom_schema_strips_supplier() -> None:
config = SBOM_SCHEMA({"supplier": " Acme Corp ", "contact": "a@b.c"})
assert config["supplier"] == "Acme Corp"
@pytest.mark.usefixtures("esp32_target")
@pytest.mark.parametrize(
("value", "match"),
[
({"supplier": "Acme Corp"}, "at least one of contact, url"),
({"supplier": "", "contact": "a@b.c"}, "must not be empty"),
({"supplier": " ", "contact": "a@b.c"}, "must not be empty"),
({"supplier": "Acme\nCorp", "contact": "a@b.c"}, "line breaks"),
({"contact": "psirt@acme.example"}, "required key not provided"),
({"supplier": "Acme (EU)", "contact": "a@b.c"}, "parentheses"),
({"supplier": "Acme", "contact": "psirt"}, "email address"),
({"supplier": "Acme", "contact": "a b@c.d"}, "email address"),
({"supplier": "Acme", "url": "file:///security.txt"}, "http or https"),
({"supplier": "Acme", "url": "acme.example"}, "URL scheme"),
],
)
def test_sbom_schema_invalid(value: dict[str, str], match: str) -> None:
"""Values esp-idf-sbom would abort on are rejected during validation."""
with pytest.raises(cv.Invalid, match=match):
SBOM_SCHEMA(value)
@pytest.mark.parametrize("platform", ["esp8266", "rp2040", "bk72xx", "host"])
def test_sbom_schema_rejects_other_platforms(platform: str) -> None:
"""Only ESP32 builds use it, so elsewhere it must not look like it works."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
with pytest.raises(cv.Invalid, match=f"only used on ESP32, not on {platform}"):
SBOM_SCHEMA({"supplier": "Acme Corp", "contact": "a@b.c"})
def test_valid_project_name_valid() -> None:
"""Test valid_project_name accepts valid project names."""
assert valid_project_name("esphome.my_project") == "esphome.my_project"
+17 -2
View File
@@ -17,6 +17,7 @@ from esphome.const import (
from esphome.core import CORE, Library
from esphome.espidf.component import (
_emit_idf_component,
_remove_stale_sbom_yml,
generate_cmakelists_txt,
generate_idf_component_yml,
generate_idf_components,
@@ -1401,10 +1402,24 @@ def test_emit_idf_component_sbom_yml_error_removes_generated(tmp_path, monkeypat
_emit_idf_component(c)
assert not (build / "sbom.yml").exists()
# A manifest copied from the library is not ESPHome's to remove
# A local library's build dir is ESPHome's, so an earlier copy goes too
(build / "sbom.yml").write_text("name: copied\n")
_emit_idf_component(c)
assert (build / "sbom.yml").read_text() == "name: copied\n"
assert not (build / "sbom.yml").exists()
@pytest.mark.parametrize(
("content", "removed"),
[("name: shipped\n", False), ("# Generated by ESPHome\nname: Lib\n", True)],
ids=["shipped", "generated"],
)
def test_remove_stale_sbom_yml_in_library_dir(
tmp_component, content: str, removed: bool
) -> None:
"""Where the library's own files live, only ESPHome's generated file is removed."""
(tmp_component.path / "sbom.yml").write_text(content)
_remove_stale_sbom_yml(tmp_component)
assert (tmp_component.path / "sbom.yml").exists() is not removed
def test_build_flags_dangling_flag_does_not_cross_entries(
+106 -7
View File
@@ -111,6 +111,8 @@ def _direct_url(url: str | None, commit: str | None) -> str:
),
(json.dumps([]), (None, None)),
("not json", (None, None)),
("", (None, None)),
(" \n", (None, None)),
# No direct_url.json: installed from a package index
(None, None),
],
@@ -204,6 +206,64 @@ def test_project_manifest(build_tree: Path) -> None:
assert not (build_tree / "sbom.yml").exists()
def test_project_manifest_sbom(build_tree: Path) -> None:
"""Product details become the supplier and the document's manufacturer."""
CORE.config[CONF_ESPHOME][CONF_PROJECT] = {
CONF_NAME: "acme.thermostat",
CONF_VERSION: "1.2.3",
"sbom": {
"supplier": "Acme Corp",
"contact": "psirt@acme.example",
"url": "https://acme.example/security",
},
}
sbom.write_manifests()
assert _read(build_tree / "sbom.yml") == {
"name": "acme.thermostat",
"version": "1.2.3",
"supplier": "Organization: Acme Corp (psirt@acme.example)",
"document": {
"manufacturer": {
"name": "Organization: Acme Corp",
"contact": "psirt@acme.example",
"url": "https://acme.example/security",
}
},
}
def test_project_manifest_sbom_url_only(build_tree: Path) -> None:
CORE.config[CONF_ESPHOME][CONF_PROJECT] = {
CONF_NAME: "acme.thermostat",
CONF_VERSION: "1.2.3",
"sbom": {"supplier": "Acme Corp", "url": "https://acme.example"},
}
sbom.write_manifests()
manifest = _read(build_tree / "sbom.yml")
assert manifest["supplier"] == "Organization: Acme Corp"
assert manifest["document"] == {
"manufacturer": {
"name": "Organization: Acme Corp",
"url": "https://acme.example",
}
}
def test_project_manifest_sbom_contact_only(build_tree: Path) -> None:
CORE.config[CONF_ESPHOME][CONF_PROJECT] = {
CONF_NAME: "acme.thermostat",
CONF_VERSION: "1.2.3",
"sbom": {"supplier": "Acme Corp", "contact": "psirt@acme.example"},
}
sbom.write_manifests()
assert _read(build_tree / "sbom.yml")["document"] == {
"manufacturer": {
"name": "Organization: Acme Corp",
"contact": "psirt@acme.example",
}
}
def test_external_components(build_tree: Path, tmp_path: Path) -> None:
"""Each loaded external component becomes its own package inside src."""
git_dir = tmp_path / "repo" / "components"
@@ -326,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()
@@ -339,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()
@@ -423,7 +485,7 @@ def test_checkout_vcs_tracked_remote(tmp_path: Path) -> None:
@pytest.mark.parametrize(
("overrides", "expected"),
[
# Detached HEAD: falls back to origin
# A branch that tracks nothing: falls back to origin
(
{"config --get branch.dev.remote": GitCommandError("unset")},
("https://github.com/someone/esphome.git", "abc"),
@@ -436,7 +498,7 @@ def test_checkout_vcs_tracked_remote(tmp_path: Path) -> None:
("https://github.com/someone/esphome.git", None),
),
],
ids=["detached", "no-remote", "pushed-check-fails"],
ids=["untracked-branch", "no-remote", "pushed-check-fails"],
)
def test_checkout_vcs_remote_fallbacks(
tmp_path: Path, overrides: dict[str, str | Exception], expected: tuple
@@ -448,16 +510,32 @@ def test_checkout_vcs_remote_fallbacks(
assert sbom._checkout_vcs(tmp_path) == expected
def test_esphome_vcs_editable_without_git(tmp_path: Path) -> None:
"""A local tree without .git, like the Docker image's, keeps the release description."""
@pytest.mark.parametrize(
("docker", "expected"),
[("true", None), (None, (None, None))],
ids=["docker", "other"],
)
def test_esphome_vcs_editable_without_git(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
docker: str | None,
expected: tuple | None,
) -> None:
"""Only the official image's tree without .git keeps the release description."""
if docker is None:
monkeypatch.delenv("ESPHOME_IS_DOCKER_IMAGE", raising=False)
else:
monkeypatch.setenv("ESPHOME_IS_DOCKER_IMAGE", docker)
dist = MagicMock()
dist.read_text.return_value = _editable_direct_url(tmp_path)
with (
patch("importlib.metadata.distribution", return_value=dist),
patch.object(sbom, "run_git_command") as run_git,
):
assert sbom._esphome_vcs() is None
assert sbom._esphome_vcs() == expected
run_git.assert_not_called()
assert ("local folder without git" in caplog.text) is (docker is None)
@pytest.mark.parametrize(
@@ -615,3 +693,24 @@ def test_checkout_vcs_pushed_check_failure_warns(
with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)):
assert sbom._checkout_vcs(tmp_path)[1] is None
assert "cannot check whether origin has HEAD" in caplog.text
def test_checkout_remote_detached(tmp_path: Path) -> None:
"""A detached HEAD uses origin without looking up a tracked remote."""
outputs = _checkout_outputs()
outputs["rev-parse --abbrev-ref HEAD"] = "HEAD"
with patch.object(sbom, "run_git_command", side_effect=_fake_git(outputs)) as run:
assert sbom._checkout_remote(tmp_path) == (
"origin",
"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
+9 -5
View File
@@ -1782,14 +1782,18 @@ def test_udp_channel_no_progress_names_last_error() -> None:
"""Without refusals, the no-progress timeout names the last socket error."""
sock = Mock()
sock.send.side_effect = BlockingIOError(35, "full")
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.settimeout(0.2)
channel.sendall(b"x")
# A hand-driven clock, so a slow runner cannot pass the timeout before the first send
now = [100.0]
with (
patch.object(espota2, "time", Mock(monotonic=lambda: now[0])),
patch("select.select", return_value=([], [], [])),
pytest.raises(espota2.OTANetworkError, match="no progress.*full"),
):
while True:
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.settimeout(0.2)
channel.sendall(b"x")
channel._pump()
now[0] += 1.0
with pytest.raises(espota2.OTANetworkError, match="no progress.*full"):
channel._pump()