mirror of
https://github.com/esphome/esphome.git
synced 2026-10-24 06:55:37 +00:00
Compare commits
10
Commits
libretiny_1_14_0
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbc1bc02d | ||
|
|
701e5746b0 | ||
|
|
9d30bcc721 | ||
|
|
acb89a8676 | ||
|
|
08d231991b | ||
|
|
253db1e0d5 | ||
|
|
bb07d04c90 | ||
|
|
3c1e53628f | ||
|
|
d515e8b2e3 | ||
|
|
49d4b83dd2 |
No files matched your search
@@ -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
|
||||
@@ -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; };
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ CONF_BITRATE = "bitrate"
|
||||
CONF_BLUE_CHANNEL = "blue_channel"
|
||||
CONF_BORDER_ROUTER = "border_router"
|
||||
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
|
||||
CONF_BUTTONS = "buttons"
|
||||
CONF_BYTE_ORDER = "byte_order"
|
||||
CONF_CHANNEL_COLORS = "channel_colors"
|
||||
CONF_CLEAR_CHANNEL = "clear_channel"
|
||||
|
||||
@@ -18,6 +18,7 @@ from esphome.components import (
|
||||
text_sensor,
|
||||
valve,
|
||||
)
|
||||
from esphome.components.const import CONF_BUTTONS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
@@ -167,7 +168,6 @@ VALVE_TYPES = {
|
||||
|
||||
|
||||
CONF_ALARM_CONTROL_PANELS = "alarm_control_panels"
|
||||
CONF_BUTTONS = "buttons"
|
||||
CONF_CLIMATES = "climates"
|
||||
CONF_COVERS = "covers"
|
||||
CONF_DATETIMES = "datetimes"
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -56,6 +56,7 @@ CONF_HTTP_REQUEST_ID = "http_request_id"
|
||||
CONF_USERAGENT = "useragent"
|
||||
CONF_FOLLOW_REDIRECTS = "follow_redirects"
|
||||
CONF_REDIRECT_LIMIT = "redirect_limit"
|
||||
CONF_MAX_CONCURRENT_REQUESTS = "max_concurrent_requests"
|
||||
CONF_BUFFER_SIZE_RX = "buffer_size_rx"
|
||||
CONF_BUFFER_SIZE_TX = "buffer_size_tx"
|
||||
CONF_TLS_BUFFER_SIZE_RX = "tls_buffer_size_rx"
|
||||
@@ -144,6 +145,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
): cv.string,
|
||||
cv.Optional(CONF_FOLLOW_REDIRECTS, True): cv.boolean,
|
||||
cv.Optional(CONF_REDIRECT_LIMIT, 3): cv.int_,
|
||||
# Each request holds a client, and a TLS client about 40 kB
|
||||
cv.Optional(CONF_MAX_CONCURRENT_REQUESTS, default=1): cv.int_range(
|
||||
min=1, max=4
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_TIMEOUT, default="4.5s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
@@ -193,6 +198,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_useragent(config[CONF_USERAGENT]))
|
||||
cg.add(var.set_follow_redirects(config[CONF_FOLLOW_REDIRECTS]))
|
||||
cg.add(var.set_redirect_limit(config[CONF_REDIRECT_LIMIT]))
|
||||
if (max_concurrent := config[CONF_MAX_CONCURRENT_REQUESTS]) != 1:
|
||||
cg.add(var.set_max_concurrent_requests(max_concurrent))
|
||||
|
||||
if CORE.is_esp8266 and not config[CONF_ESP8266_DISABLE_SSL_SUPPORT]:
|
||||
cg.add_define("USE_HTTP_REQUEST_ESP8266_HTTPS")
|
||||
@@ -312,19 +319,19 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend(
|
||||
"http_request.get",
|
||||
HttpRequestSendAction,
|
||||
HTTP_REQUEST_GET_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
synchronous=False,
|
||||
)
|
||||
@automation.register_action(
|
||||
"http_request.post",
|
||||
HttpRequestSendAction,
|
||||
HTTP_REQUEST_POST_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
synchronous=False,
|
||||
)
|
||||
@automation.register_action(
|
||||
"http_request.send",
|
||||
HttpRequestSendAction,
|
||||
HTTP_REQUEST_SEND_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
synchronous=False,
|
||||
)
|
||||
async def http_request_action_to_code(
|
||||
config: ConfigType,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "http_request.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cinttypes>
|
||||
@@ -14,8 +16,10 @@ void HttpRequestComponent::dump_config() {
|
||||
" Timeout: %" PRIu32 "ms\n"
|
||||
" User-Agent: %s\n"
|
||||
" Follow redirects: %s\n"
|
||||
" Redirect limit: %d",
|
||||
this->timeout_, this->useragent_, YESNO(this->follow_redirects_), this->redirect_limit_);
|
||||
" Redirect limit: %d\n"
|
||||
" Max concurrent requests: %u",
|
||||
this->timeout_, this->useragent_, YESNO(this->follow_redirects_), this->redirect_limit_,
|
||||
this->max_concurrent_);
|
||||
if (this->watchdog_timeout_ > 0) {
|
||||
ESP_LOGCONFIG(TAG, " Watchdog Timeout: %" PRIu32 "ms", this->watchdog_timeout_);
|
||||
}
|
||||
@@ -32,4 +36,209 @@ std::string HttpContainer::get_response_header(const std::string &header_name) {
|
||||
return "";
|
||||
}
|
||||
|
||||
void PendingRequest::free_buffer() {
|
||||
if (this->buf != nullptr)
|
||||
RAMAllocator<uint8_t>().deallocate(this->buf, this->buf_size);
|
||||
this->buf = nullptr;
|
||||
}
|
||||
|
||||
void HttpRequestComponent::setup() {
|
||||
// An on_boot automation may already have queued a request
|
||||
if (this->requests_ == nullptr)
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void HttpRequestComponent::loop() {
|
||||
if (!this->process_())
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void HttpRequestComponent::on_safe_shutdown() {
|
||||
// Called before any on_shutdown(). Drop the requests already made, so that they do not hold up the reboot;
|
||||
// only the ones that on_shutdown automations make are sent.
|
||||
this->shutting_down_ = true;
|
||||
this->busy_++;
|
||||
unsigned dropped = 0;
|
||||
for (PendingRequest *request = this->requests_; request != nullptr; request = request->next) {
|
||||
if (request->finished || request->cancelled)
|
||||
continue;
|
||||
dropped++;
|
||||
request->cancelled = true;
|
||||
// As script.stop would, so that a script that was waiting for it can run again from on_shutdown
|
||||
request->handler(request, RequestEvent::REQUEST_EVENT_ABANDON);
|
||||
}
|
||||
this->busy_--;
|
||||
if (dropped != 0) {
|
||||
ESP_LOGW(TAG, "Shutting down; %u requests dropped", dropped);
|
||||
}
|
||||
if (this->busy_ == 0)
|
||||
this->remove_dead_();
|
||||
}
|
||||
|
||||
void HttpRequestComponent::on_shutdown() {
|
||||
// Shutdown runs no loop(), so finish the requests here, blocking as they did before. A forced reboot calls
|
||||
// only this, so it also finishes the requests made before it.
|
||||
this->shutting_down_ = true;
|
||||
this->drain_();
|
||||
}
|
||||
|
||||
void HttpRequestComponent::submit(PendingRequest *request) {
|
||||
this->busy_++;
|
||||
uint8_t running = 0;
|
||||
uint8_t waiting = 0;
|
||||
PendingRequest **link = &this->requests_;
|
||||
for (; *link != nullptr; link = &(*link)->next) {
|
||||
const PendingRequest *other = *link;
|
||||
if (other->finished || other->cancelled)
|
||||
continue;
|
||||
if (other->container == nullptr) {
|
||||
waiting++;
|
||||
} else {
|
||||
running++;
|
||||
}
|
||||
}
|
||||
*link = request;
|
||||
this->enable_loop();
|
||||
if (waiting >= MAX_QUEUED) {
|
||||
ESP_LOGW(TAG, "Too many requests waiting; request dropped");
|
||||
request->failed = true;
|
||||
this->finish_(request);
|
||||
} else if (waiting == 0 && running < this->max_concurrent_) {
|
||||
// Start at once; one that cannot start is reported at once
|
||||
this->begin_request_(request);
|
||||
if (request->failed)
|
||||
this->finish_(request);
|
||||
}
|
||||
this->busy_--;
|
||||
if (this->busy_ != 0)
|
||||
return;
|
||||
if (this->shutting_down_) {
|
||||
this->drain_();
|
||||
} else {
|
||||
this->remove_dead_();
|
||||
}
|
||||
}
|
||||
|
||||
void HttpRequestComponent::cancel(void *owner) {
|
||||
for (PendingRequest *request = this->requests_; request != nullptr; request = request->next) {
|
||||
if (request->owner == owner)
|
||||
request->cancelled = true;
|
||||
}
|
||||
if (this->busy_ == 0)
|
||||
this->remove_dead_();
|
||||
}
|
||||
|
||||
bool HttpRequestComponent::process_() {
|
||||
this->busy_++;
|
||||
// The first max_concurrent_ live requests run; a request finished here frees its slot on the next pass
|
||||
uint8_t slots = this->max_concurrent_;
|
||||
for (PendingRequest *request = this->requests_; request != nullptr && slots != 0; request = request->next) {
|
||||
if (request->finished || request->cancelled)
|
||||
continue;
|
||||
slots--;
|
||||
if (request->container == nullptr)
|
||||
this->begin_request_(request);
|
||||
if (!request->cancelled && (request->failed || this->advance_(request)))
|
||||
this->finish_(request);
|
||||
}
|
||||
this->busy_--;
|
||||
if (this->busy_ == 0)
|
||||
this->remove_dead_();
|
||||
return this->requests_ != nullptr;
|
||||
}
|
||||
|
||||
void HttpRequestComponent::drain_() {
|
||||
// A call further up holds request pointers, and runs the new requests itself
|
||||
if (this->busy_ != 0)
|
||||
return;
|
||||
while (this->process_()) {
|
||||
App.feed_wdt();
|
||||
delay(1);
|
||||
}
|
||||
}
|
||||
|
||||
void HttpRequestComponent::begin_request_(PendingRequest *request) {
|
||||
request->start_time = millis();
|
||||
request->handler(request, RequestEvent::REQUEST_EVENT_START);
|
||||
// Stopped from the request's own lambdas: it is removed without being polled
|
||||
if (!request->cancelled && request->container == nullptr)
|
||||
request->failed = true;
|
||||
}
|
||||
|
||||
bool HttpRequestComponent::advance_(PendingRequest *request) {
|
||||
if (!request->reading) {
|
||||
auto progress = request->container->poll();
|
||||
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING)
|
||||
return false;
|
||||
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED) {
|
||||
request->failed = true;
|
||||
return true;
|
||||
}
|
||||
if (!request->capture)
|
||||
return true;
|
||||
request->buf = RAMAllocator<uint8_t>().allocate(request->buf_size);
|
||||
if (request->buf == nullptr) {
|
||||
// As before, on_response still fires, with an empty body
|
||||
ESP_LOGW(TAG, "Could not allocate %zu bytes for the response body", request->buf_size);
|
||||
return true;
|
||||
}
|
||||
request->reading = true;
|
||||
// millis(), not the loop time: poll() may have blocked earlier in this pass
|
||||
request->last_data_time = millis();
|
||||
}
|
||||
// NOTE: HttpContainer::read() has non-BSD socket semantics - see http_request.h
|
||||
// millis(), not the loop time: the budget is shorter than one pass of the loop
|
||||
const uint32_t start = millis();
|
||||
while (request->read_index < request->buf_size) {
|
||||
int read_or_error =
|
||||
request->container->read_available(request->buf + request->read_index, request->buf_size - request->read_index);
|
||||
const uint32_t now = millis();
|
||||
// As before, a body that is cut off still fires on_response with what was read
|
||||
if (read_or_error < 0) {
|
||||
ESP_LOGW(TAG, "Response body read failed after %zu bytes", request->read_index);
|
||||
return true;
|
||||
}
|
||||
if (read_or_error == 0) {
|
||||
if (request->container->is_read_complete())
|
||||
return true;
|
||||
if (now - request->last_data_time < request->timeout)
|
||||
return false;
|
||||
ESP_LOGW(TAG, "Response body timed out after %zu bytes", request->read_index);
|
||||
return true;
|
||||
}
|
||||
request->read_index += read_or_error;
|
||||
request->last_data_time = now;
|
||||
if (now - start >= READ_BUDGET_MS)
|
||||
return false;
|
||||
}
|
||||
// As before, on_response still fires, with the part that fitted
|
||||
if (!request->container->is_read_complete()) {
|
||||
ESP_LOGW(TAG, "Response body is larger than max_response_buffer_size; kept the first %zu bytes", request->buf_size);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void HttpRequestComponent::finish_(PendingRequest *request) {
|
||||
request->finished = true;
|
||||
// The body was read over several passes, so time it from the start, as a blocking read would
|
||||
if (request->reading && request->container != nullptr)
|
||||
request->container->duration_ms = millis() - request->start_time;
|
||||
request->handler(request, RequestEvent::REQUEST_EVENT_FINISH);
|
||||
}
|
||||
|
||||
void HttpRequestComponent::remove_dead_() {
|
||||
PendingRequest **link = &this->requests_;
|
||||
while (PendingRequest *request = *link) {
|
||||
if (!request->finished && !request->cancelled) {
|
||||
link = &request->next;
|
||||
continue;
|
||||
}
|
||||
*link = request->next;
|
||||
if (request->container != nullptr)
|
||||
request->container->end();
|
||||
request->free_buffer();
|
||||
request->handler(request, RequestEvent::REQUEST_EVENT_DESTROY);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::http_request
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/alloc_helpers.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -186,6 +187,13 @@ enum class HttpReadLoopResult : uint8_t {
|
||||
TIMEOUT, ///< Timeout waiting for data, caller should exit loop
|
||||
};
|
||||
|
||||
/// Progress of a request started with HttpRequestComponent::begin()
|
||||
enum class HttpRequestProgress : uint8_t {
|
||||
HTTP_REQUEST_PROGRESS_PENDING, ///< Still connecting, sending, or waiting for the response headers
|
||||
HTTP_REQUEST_PROGRESS_DONE, ///< Response headers received; status_code is set and the body can be read
|
||||
HTTP_REQUEST_PROGRESS_FAILED, ///< No response; the container must still be ended
|
||||
};
|
||||
|
||||
/// Process a read result with timeout tracking and delay handling
|
||||
/// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error
|
||||
/// @param last_data_time Time of last successful read, updated when data received
|
||||
@@ -249,8 +257,15 @@ class HttpContainer : public Parented<HttpRequestComponent> {
|
||||
* - http_read_fully(): for simple "read N bytes" operations
|
||||
*/
|
||||
virtual int read(uint8_t *buf, size_t max_len) = 0;
|
||||
/// Like read(), but returns 0 at once when no data has arrived yet, where read() may wait for it.
|
||||
virtual int read_available(uint8_t *buf, size_t max_len) { return this->read(buf, max_len); }
|
||||
virtual void end() = 0;
|
||||
|
||||
/// Advance a request started with HttpRequestComponent::begin(); call from the main loop until it is no
|
||||
/// longer pending. Containers from start() are already done. Most calls return quickly, but on ESP-IDF the
|
||||
/// first call for each host waits for the name lookup and the TCP connection, up to the whole timeout.
|
||||
virtual HttpRequestProgress poll() { return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE; }
|
||||
|
||||
void set_secure(bool secure) { this->secure_ = secure; }
|
||||
void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; }
|
||||
uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; }
|
||||
@@ -332,10 +347,56 @@ class HttpRequestResponseTrigger final : public Trigger<std::shared_ptr<HttpCont
|
||||
}
|
||||
};
|
||||
|
||||
/// What HttpRequestComponent asks of the action that owns a request
|
||||
enum class RequestEvent : uint8_t {
|
||||
REQUEST_EVENT_START, ///< Build the request and call begin()
|
||||
REQUEST_EVENT_FINISH, ///< Fire the triggers and continue the action chain
|
||||
REQUEST_EVENT_DESTROY, ///< Free the request
|
||||
REQUEST_EVENT_ABANDON, ///< Dropped at shutdown: stop the action, so that it can run again
|
||||
};
|
||||
|
||||
/// A request from an HttpRequestSendAction, run from HttpRequestComponent::loop().
|
||||
/// The action derives from it to add the trigger arguments.
|
||||
struct PendingRequest {
|
||||
using Handler = void (*)(PendingRequest *request, RequestEvent event);
|
||||
PendingRequest(Handler handler, void *owner) : handler(handler), owner(owner) {}
|
||||
PendingRequest(const PendingRequest &) = delete;
|
||||
PendingRequest &operator=(const PendingRequest &) = delete;
|
||||
void free_buffer();
|
||||
|
||||
PendingRequest *next{nullptr};
|
||||
Handler handler;
|
||||
// The action, so that a stop of it can find its requests
|
||||
void *owner;
|
||||
// nullptr until the request has started
|
||||
std::shared_ptr<HttpContainer> container;
|
||||
uint8_t *buf{nullptr};
|
||||
size_t buf_size{0};
|
||||
size_t read_index{0};
|
||||
uint32_t last_data_time{0};
|
||||
uint32_t timeout{0};
|
||||
uint32_t start_time{0};
|
||||
bool capture{false};
|
||||
bool reading{false};
|
||||
bool failed{false};
|
||||
bool finished{false};
|
||||
bool cancelled{false};
|
||||
};
|
||||
|
||||
class HttpRequestComponent : public Component {
|
||||
public:
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void on_safe_shutdown() override;
|
||||
void on_shutdown() override;
|
||||
|
||||
/// For HttpRequestSendAction: queue a request and take ownership of it. Requests start in order, up to
|
||||
/// max_concurrent_requests at a time; one that can start at once is started here.
|
||||
void submit(PendingRequest *request);
|
||||
/// For HttpRequestSendAction: drop the requests of a stopped action without firing their triggers.
|
||||
void cancel(void *owner);
|
||||
|
||||
void set_useragent(const char *useragent) { this->useragent_ = useragent; }
|
||||
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
|
||||
@@ -349,6 +410,7 @@ class HttpRequestComponent : public Component {
|
||||
}
|
||||
void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; }
|
||||
void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; }
|
||||
void set_max_concurrent_requests(uint8_t max_concurrent) { this->max_concurrent_ = max_concurrent; }
|
||||
|
||||
std::shared_ptr<HttpContainer> get(const char *url) { return this->start(url, "GET", "", std::vector<Header>{}); }
|
||||
std::shared_ptr<HttpContainer> get(const char *url, const std::vector<Header> &request_headers) {
|
||||
@@ -491,16 +553,44 @@ class HttpRequestComponent : public Component {
|
||||
return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers);
|
||||
}
|
||||
|
||||
/// Start a request without waiting for the response, then poll() the container until it is not pending.
|
||||
/// Returns nullptr when the request cannot be started. Platforms without a non-blocking client complete
|
||||
/// the request here.
|
||||
virtual std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms) {
|
||||
return this->perform(url, method, body, request_headers, lower_case_collect_headers, timeout_ms);
|
||||
}
|
||||
|
||||
protected:
|
||||
virtual std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms) = 0;
|
||||
/// Longest time one loop() pass spends reading a response body
|
||||
static constexpr uint32_t READ_BUDGET_MS = 10;
|
||||
/// Requests that wait for a free slot; more are dropped with on_error
|
||||
static constexpr uint8_t MAX_QUEUED = 4;
|
||||
|
||||
bool process_();
|
||||
void drain_();
|
||||
void begin_request_(PendingRequest *request);
|
||||
bool advance_(PendingRequest *request);
|
||||
void finish_(PendingRequest *request);
|
||||
void remove_dead_();
|
||||
|
||||
const char *useragent_{nullptr};
|
||||
bool follow_redirects_{};
|
||||
uint16_t redirect_limit_{};
|
||||
uint32_t timeout_{4500};
|
||||
uint32_t watchdog_timeout_{0};
|
||||
// Oldest first; the first max_concurrent_ live requests run
|
||||
PendingRequest *requests_{nullptr};
|
||||
// Depth of calls that hold request pointers; finished and cancelled requests are freed when it returns to 0
|
||||
uint8_t busy_{0};
|
||||
uint8_t max_concurrent_{1};
|
||||
bool shutting_down_{false};
|
||||
};
|
||||
|
||||
template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...> {
|
||||
@@ -542,7 +632,42 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
|
||||
this->max_response_buffer_size_ = max_response_buffer_size;
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
void play_complex(const Ts &...x) override {
|
||||
this->num_running_++;
|
||||
this->parent_->submit(new Request(this, x...)); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override { /* see play_complex */ }
|
||||
|
||||
protected:
|
||||
struct Request final : PendingRequest {
|
||||
Request(HttpRequestSendAction *action, const Ts &...x)
|
||||
: PendingRequest(&HttpRequestSendAction::handle_event, action), args(x...) {}
|
||||
std::tuple<stored_trigger_arg_t<Ts>...> args;
|
||||
};
|
||||
|
||||
static void handle_event(PendingRequest *request, RequestEvent event) {
|
||||
auto *self = static_cast<Request *>(request);
|
||||
auto *action = static_cast<HttpRequestSendAction *>(request->owner);
|
||||
switch (event) {
|
||||
case RequestEvent::REQUEST_EVENT_START:
|
||||
std::apply([action, self](auto &...x) { action->start_request_(*self, x...); }, self->args);
|
||||
break;
|
||||
case RequestEvent::REQUEST_EVENT_FINISH:
|
||||
action->finish_(*self);
|
||||
break;
|
||||
case RequestEvent::REQUEST_EVENT_DESTROY:
|
||||
delete self; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
break;
|
||||
case RequestEvent::REQUEST_EVENT_ABANDON:
|
||||
action->stop_complex();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void stop() override { this->parent_->cancel(this); }
|
||||
|
||||
void start_request_(Request &request, const Ts &...x) {
|
||||
std::string body;
|
||||
if (this->body_.has_value()) {
|
||||
body = this->body_.value(x...);
|
||||
@@ -559,62 +684,47 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
|
||||
request_headers.push_back({header.name, header.value(x...)});
|
||||
}
|
||||
|
||||
const uint32_t timeout = this->timeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout();
|
||||
auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body,
|
||||
request_headers, this->lower_case_collect_headers_, timeout);
|
||||
|
||||
auto captured_args = std::make_tuple(x...);
|
||||
|
||||
if (container == nullptr) {
|
||||
std::apply([this](Ts... captured_args_inner) { this->error_trigger_.trigger(captured_args_inner...); },
|
||||
captured_args);
|
||||
return;
|
||||
}
|
||||
|
||||
request.timeout = this->timeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout();
|
||||
#ifdef USE_HTTP_REQUEST_RESPONSE
|
||||
if (this->capture_response_.value(x...)) {
|
||||
size_t max_length = this->max_response_buffer_size_;
|
||||
std::string response_body;
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
uint8_t *buf = allocator.allocate(max_length);
|
||||
if (buf != nullptr) {
|
||||
// NOTE: HttpContainer::read() has non-BSD socket semantics - see top of this file
|
||||
// Use http_read_loop_result() helper instead of checking return values directly
|
||||
size_t read_index = 0;
|
||||
uint32_t last_data_time = millis();
|
||||
const uint32_t read_timeout = timeout;
|
||||
while (container->get_bytes_read() < max_length) {
|
||||
int read_or_error = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512));
|
||||
App.feed_wdt();
|
||||
yield();
|
||||
auto result =
|
||||
http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
|
||||
if (result == HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
if (result != HttpReadLoopResult::DATA)
|
||||
break; // COMPLETE, ERROR, or TIMEOUT
|
||||
read_index += read_or_error;
|
||||
}
|
||||
response_body.reserve(read_index);
|
||||
response_body.assign((char *) buf, read_index);
|
||||
allocator.deallocate(buf, max_length);
|
||||
}
|
||||
std::apply(
|
||||
[this, &container, &response_body](Ts... captured_args_inner) {
|
||||
this->success_trigger_with_response_.trigger(container, response_body, captured_args_inner...);
|
||||
},
|
||||
captured_args);
|
||||
} else
|
||||
request.capture = this->capture_response_.value(x...);
|
||||
#endif
|
||||
{
|
||||
std::apply([this, &container](
|
||||
Ts... captured_args_inner) { this->success_trigger_.trigger(container, captured_args_inner...); },
|
||||
captured_args);
|
||||
request.buf_size = this->max_response_buffer_size_;
|
||||
request.container = this->parent_->begin(this->url_.value(x...).c_str(), this->method_.value(x...), std::move(body),
|
||||
request_headers, this->lower_case_collect_headers_, request.timeout);
|
||||
}
|
||||
|
||||
void finish_(Request &request) {
|
||||
auto &container = request.container;
|
||||
if (request.failed) {
|
||||
std::apply([this](auto &...args) { this->error_trigger_.trigger(args...); }, request.args);
|
||||
}
|
||||
container->end();
|
||||
#ifdef USE_HTTP_REQUEST_RESPONSE
|
||||
else if (request.capture) {
|
||||
std::string response_body;
|
||||
if (request.buf != nullptr)
|
||||
response_body.assign(reinterpret_cast<char *>(request.buf), request.read_index);
|
||||
request.free_buffer();
|
||||
std::apply(
|
||||
[this, &container, &response_body](auto &...args) {
|
||||
this->success_trigger_with_response_.trigger(container, response_body, args...);
|
||||
},
|
||||
request.args);
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
std::apply([this, &container](auto &...args) { this->success_trigger_.trigger(container, args...); },
|
||||
request.args);
|
||||
}
|
||||
// End it before the chain can start another request, so that one client is held at a time
|
||||
if (container != nullptr) {
|
||||
container->end();
|
||||
container.reset();
|
||||
}
|
||||
// A stop from the triggers ended this run; a run started after it continues the chain instead
|
||||
if (!request.cancelled)
|
||||
std::apply([this](auto &...args) { this->play_next_(args...); }, request.args);
|
||||
}
|
||||
|
||||
protected:
|
||||
void encode_json_(Ts... x, JsonObject root) {
|
||||
for (const auto &item : this->json_) {
|
||||
auto val = item.second;
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/select.h>
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/components/watchdog/watchdog.h"
|
||||
|
||||
@@ -20,6 +25,10 @@
|
||||
namespace esphome::http_request {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "http_request");
|
||||
|
||||
// Request body sent per esp_http_client_write() call, and the time one poll() spends sending it
|
||||
static constexpr size_t SEND_CHUNK_SIZE = 1024;
|
||||
static constexpr uint32_t SEND_BUDGET_MS = 10;
|
||||
static constexpr uint32_t ERROR_DURATION_MS = 1000;
|
||||
|
||||
void HttpRequestIDF::dump_config() {
|
||||
@@ -51,10 +60,9 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const char *method, const std::string &body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms) {
|
||||
std::shared_ptr<HttpContainerIDF> HttpRequestIDF::create_container_(
|
||||
const char *url, const char *method, const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers, uint32_t timeout_ms, [[maybe_unused]] bool async) {
|
||||
if (!network::is_connected()) {
|
||||
this->status_momentary_error(ERROR_DURATION_MS);
|
||||
ESP_LOGE(TAG, "HTTP Request failed; Not connected to network");
|
||||
@@ -106,11 +114,12 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
|
||||
config.buffer_size = this->buffer_size_rx_;
|
||||
config.buffer_size_tx = this->buffer_size_tx_;
|
||||
|
||||
const uint32_t start = millis();
|
||||
const uint32_t watchdog_timeout = this->get_watchdog_timeout(timeout_ms);
|
||||
watchdog::WatchdogManager wdm(watchdog_timeout);
|
||||
|
||||
config.event_handler = http_event_handler;
|
||||
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
|
||||
// Only TLS has a non-blocking connect worth having: the handshake. A plain connection waits for
|
||||
// the TCP connection either way.
|
||||
config.is_async = async && secure;
|
||||
#endif
|
||||
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
if (client == nullptr) {
|
||||
@@ -121,7 +130,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
|
||||
|
||||
std::shared_ptr<HttpContainerIDF> container = std::make_shared<HttpContainerIDF>(client);
|
||||
container->set_parent(this);
|
||||
container->set_watchdog_timeout(watchdog_timeout);
|
||||
container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms));
|
||||
container->timeout_ms_ = timeout_ms;
|
||||
container->async_ = config.is_async;
|
||||
|
||||
container->set_secure(secure);
|
||||
|
||||
@@ -132,101 +143,229 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const ch
|
||||
esp_http_client_set_header(client, header.name.c_str(), header.value.c_str());
|
||||
}
|
||||
|
||||
const int body_len = body.length();
|
||||
return container;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, body_len);
|
||||
if (err != ESP_OK) {
|
||||
this->status_momentary_error(ERROR_DURATION_MS);
|
||||
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const char *url, const char *method, const std::string &body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms) {
|
||||
std::shared_ptr<HttpContainerIDF> container = this->start_container_(url, method, std::string(body), request_headers,
|
||||
lower_case_collect_headers, timeout_ms, false);
|
||||
if (container == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
// The same stages as begin(), run here until the response headers are in
|
||||
watchdog::WatchdogManager wdm(container->get_watchdog_timeout());
|
||||
HttpRequestProgress progress;
|
||||
while ((progress = container->poll()) == HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING) {
|
||||
container->feed_wdt();
|
||||
delay(1);
|
||||
}
|
||||
if (progress == HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED) {
|
||||
container->end();
|
||||
return nullptr;
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
if (body_len > 0) {
|
||||
int write_left = body_len;
|
||||
int write_index = 0;
|
||||
const char *buf = body.c_str();
|
||||
while (write_left > 0) {
|
||||
int written = esp_http_client_write(client, buf + write_index, write_left);
|
||||
if (written <= 0) {
|
||||
err = ESP_FAIL;
|
||||
std::shared_ptr<HttpContainer> HttpRequestIDF::begin(const char *url, const char *method, std::string &&body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms) {
|
||||
return this->start_container_(url, method, std::move(body), request_headers, lower_case_collect_headers, timeout_ms,
|
||||
true);
|
||||
}
|
||||
|
||||
std::shared_ptr<HttpContainerIDF> HttpRequestIDF::start_container_(
|
||||
const char *url, const char *method, std::string &&body, const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers, uint32_t timeout_ms, bool async) {
|
||||
std::shared_ptr<HttpContainerIDF> container =
|
||||
this->create_container_(url, method, request_headers, lower_case_collect_headers, timeout_ms, async);
|
||||
if (container == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
container->body_ = std::move(body);
|
||||
container->start_time_ = millis();
|
||||
container->progress_time_ = container->start_time_;
|
||||
container->redirects_left_ = this->follow_redirects_ ? this->redirect_limit_ : 0;
|
||||
container->stage_ = HttpContainerIDF::Stage::STAGE_CONNECT;
|
||||
return container;
|
||||
}
|
||||
|
||||
HttpRequestIDF *HttpContainerIDF::idf_parent_() { return static_cast<HttpRequestIDF *>(this->parent_); }
|
||||
|
||||
HttpRequestProgress HttpContainerIDF::fail_(esp_err_t err) {
|
||||
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
|
||||
this->parent_->status_momentary_error(ERROR_DURATION_MS);
|
||||
this->stage_ = Stage::STAGE_FAILED;
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
|
||||
}
|
||||
|
||||
HttpRequestProgress HttpContainerIDF::wait_() {
|
||||
// millis(), not the loop time: progress_time_ is set after calls that block within this pass
|
||||
if (millis() - this->progress_time_ >= this->timeout_ms_) {
|
||||
return this->fail_(ESP_ERR_TIMEOUT);
|
||||
}
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
|
||||
}
|
||||
|
||||
bool HttpContainerIDF::socket_ready_(bool write) {
|
||||
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
|
||||
const int fd = esp_http_client_get_socket(this->client_);
|
||||
if (fd < 0) {
|
||||
return true;
|
||||
}
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(fd, &fds);
|
||||
timeval no_wait{};
|
||||
// An error also counts as ready, so that the call that follows reports it
|
||||
// Qualified: esphome::select is a namespace
|
||||
return ::select(fd + 1, write ? nullptr : &fds, write ? &fds : nullptr, nullptr, &no_wait) != 0;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Each stage either finishes, or returns PENDING after a call that did not wait. The waits left are
|
||||
// the name lookup and TCP connection in the first esp_http_client_open() of each hop: a non-blocking
|
||||
// esp-tls connect that times out once never completes, as select() clears the sets it keeps.
|
||||
HttpRequestProgress HttpContainerIDF::poll() {
|
||||
while (true) {
|
||||
switch (this->stage_) {
|
||||
case Stage::STAGE_DONE:
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE;
|
||||
|
||||
case Stage::STAGE_FAILED:
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_FAILED;
|
||||
|
||||
case Stage::STAGE_CONNECT: {
|
||||
// Only the first call of a hop waits
|
||||
watchdog::WatchdogManager wdm(this->connecting_ ? 0 : this->get_watchdog_timeout());
|
||||
esp_http_client_set_timeout_ms(this->client_, this->connecting_ ? 1 : this->timeout_ms_);
|
||||
this->feed_wdt();
|
||||
esp_err_t err = esp_http_client_open(this->client_, this->body_.size());
|
||||
this->feed_wdt();
|
||||
if (err == ESP_ERR_HTTP_EAGAIN) {
|
||||
this->connecting_ = true;
|
||||
return this->wait_();
|
||||
}
|
||||
this->connecting_ = false;
|
||||
if (err != ESP_OK) {
|
||||
return this->fail_(err);
|
||||
}
|
||||
#ifdef USE_HTTP_REQUEST_IDF_SOCKET_POLL
|
||||
if (this->async_) {
|
||||
// Back to a blocking socket, so that mbedtls waits for the rest of a record instead of
|
||||
// failing; reads only start once the socket is readable
|
||||
const int fd = esp_http_client_get_socket(this->client_);
|
||||
const int flags = fd < 0 ? -1 : fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) < 0) {
|
||||
ESP_LOGE(TAG, "Could not make the socket blocking: fd %d, errno %d", fd, errno);
|
||||
return this->fail_(ESP_FAIL);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
esp_http_client_set_timeout_ms(this->client_, this->timeout_ms_);
|
||||
this->progress_time_ = millis();
|
||||
this->stage_ = this->body_sent_ < this->body_.size() ? Stage::STAGE_SEND_BODY : Stage::STAGE_HEADERS;
|
||||
break;
|
||||
}
|
||||
write_left -= written;
|
||||
write_index += written;
|
||||
container->feed_wdt();
|
||||
}
|
||||
}
|
||||
|
||||
if (err != ESP_OK) {
|
||||
this->status_momentary_error(ERROR_DURATION_MS);
|
||||
ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err));
|
||||
esp_http_client_cleanup(client);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
container->feed_wdt();
|
||||
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header).
|
||||
// The read() method handles content_length == 0 specially to support chunked responses.
|
||||
container->content_length = esp_http_client_fetch_headers(client);
|
||||
container->set_chunked(esp_http_client_is_chunked_response(client));
|
||||
container->feed_wdt();
|
||||
container->status_code = esp_http_client_get_status_code(client);
|
||||
container->feed_wdt();
|
||||
container->duration_ms = millis() - start;
|
||||
if (is_success(container->status_code)) {
|
||||
return container;
|
||||
}
|
||||
|
||||
if (this->follow_redirects_) {
|
||||
auto num_redirects = this->redirect_limit_;
|
||||
while (is_redirect(container->status_code) && num_redirects > 0) {
|
||||
err = esp_http_client_set_redirection(client);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err));
|
||||
this->status_momentary_error(ERROR_DURATION_MS);
|
||||
esp_http_client_cleanup(client);
|
||||
return nullptr;
|
||||
case Stage::STAGE_SEND_BODY: {
|
||||
// esp_http_client_write() returns only once all it was given is sent, so give it a piece at a time
|
||||
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
|
||||
const uint32_t start = millis();
|
||||
while (this->body_sent_ < this->body_.size()) {
|
||||
if (!this->socket_ready_(true)) {
|
||||
return this->wait_();
|
||||
}
|
||||
const size_t len = std::min(this->body_.size() - this->body_sent_, SEND_CHUNK_SIZE);
|
||||
int written = esp_http_client_write(this->client_, this->body_.data() + this->body_sent_, len);
|
||||
this->feed_wdt();
|
||||
if (written < 0) {
|
||||
return this->fail_(ESP_FAIL);
|
||||
}
|
||||
if (written == 0) {
|
||||
return this->wait_();
|
||||
}
|
||||
this->body_sent_ += written;
|
||||
this->progress_time_ = millis();
|
||||
if (this->progress_time_ - start >= SEND_BUDGET_MS && this->body_sent_ < this->body_.size()) {
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
|
||||
}
|
||||
}
|
||||
this->body_ = std::string();
|
||||
this->stage_ = Stage::STAGE_HEADERS;
|
||||
break;
|
||||
}
|
||||
|
||||
case Stage::STAGE_HEADERS: {
|
||||
if (!this->socket_ready_(false)) {
|
||||
if (millis() - this->progress_time_ < this->timeout_ms_) {
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_PENDING;
|
||||
}
|
||||
// As perform(), where esp_http_client_fetch_headers() times out: no response is status -1
|
||||
return this->response_failed_(-1);
|
||||
}
|
||||
// The response has begun to arrive, so this waits only for the rest of the headers, or over TLS
|
||||
// the rest of the record that holds them
|
||||
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
|
||||
this->feed_wdt();
|
||||
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header)
|
||||
const int64_t length = esp_http_client_fetch_headers(this->client_);
|
||||
this->feed_wdt();
|
||||
this->duration_ms = millis() - this->start_time_;
|
||||
if (length < 0) {
|
||||
return this->response_failed_(-1);
|
||||
}
|
||||
this->content_length = length;
|
||||
this->set_chunked(esp_http_client_is_chunked_response(this->client_));
|
||||
this->status_code = esp_http_client_get_status_code(this->client_);
|
||||
if (is_success(this->status_code)) {
|
||||
this->stage_ = Stage::STAGE_DONE;
|
||||
break;
|
||||
}
|
||||
if (this->redirects_left_ > 0 && is_redirect(this->status_code)) {
|
||||
esp_err_t err = esp_http_client_set_redirection(this->client_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err));
|
||||
return this->fail_(err);
|
||||
}
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char redirect_url[256]{};
|
||||
if (esp_http_client_get_url(client, redirect_url, sizeof(redirect_url) - 1) == ESP_OK) {
|
||||
ESP_LOGV(TAG, "redirecting to url: %s", redirect_url);
|
||||
}
|
||||
char redirect_url[256]{};
|
||||
if (esp_http_client_get_url(this->client_, redirect_url, sizeof(redirect_url) - 1) == ESP_OK) {
|
||||
ESP_LOGV(TAG, "redirecting to url: %s", redirect_url);
|
||||
}
|
||||
#endif
|
||||
err = esp_http_client_open(client, 0);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err));
|
||||
this->status_momentary_error(ERROR_DURATION_MS);
|
||||
esp_http_client_cleanup(client);
|
||||
return nullptr;
|
||||
this->redirects_left_--;
|
||||
// Collect only the final response's headers; redirects do not resend the body
|
||||
this->response_headers_.clear();
|
||||
this->body_ = std::string();
|
||||
this->body_sent_ = 0;
|
||||
this->progress_time_ = millis();
|
||||
this->stage_ = Stage::STAGE_CONNECT;
|
||||
break;
|
||||
}
|
||||
if (is_redirect(this->status_code) && this->idf_parent_()->follow_redirects_) {
|
||||
ESP_LOGW(TAG, "Reach redirect limit count=%d", this->idf_parent_()->redirect_limit_);
|
||||
}
|
||||
return this->response_failed_(this->status_code);
|
||||
}
|
||||
|
||||
container->feed_wdt();
|
||||
// IDF is the only backend reusing the container across redirect hops;
|
||||
// drop the previous hop's headers (Arduino/host collect only the final response)
|
||||
container->response_headers_.clear();
|
||||
container->content_length = esp_http_client_fetch_headers(client);
|
||||
container->set_chunked(esp_http_client_is_chunked_response(client));
|
||||
container->feed_wdt();
|
||||
container->status_code = esp_http_client_get_status_code(client);
|
||||
container->feed_wdt();
|
||||
container->duration_ms = millis() - start;
|
||||
if (is_success(container->status_code)) {
|
||||
return container;
|
||||
}
|
||||
|
||||
num_redirects--;
|
||||
}
|
||||
|
||||
if (num_redirects == 0) {
|
||||
ESP_LOGW(TAG, "Reach redirect limit count=%d", this->redirect_limit_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code);
|
||||
this->status_momentary_error(ERROR_DURATION_MS);
|
||||
return container;
|
||||
HttpRequestProgress HttpContainerIDF::response_failed_(int status_code) {
|
||||
this->status_code = status_code;
|
||||
this->duration_ms = millis() - this->start_time_;
|
||||
char url[256]{};
|
||||
esp_http_client_get_url(this->client_, url, sizeof(url) - 1);
|
||||
ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, this->status_code);
|
||||
this->parent_->status_momentary_error(ERROR_DURATION_MS);
|
||||
this->stage_ = Stage::STAGE_DONE;
|
||||
return HttpRequestProgress::HTTP_REQUEST_PROGRESS_DONE;
|
||||
}
|
||||
|
||||
bool HttpContainerIDF::is_read_complete() const {
|
||||
@@ -265,8 +404,21 @@ bool HttpContainerIDF::is_read_complete() const {
|
||||
// after its internal transport timeout (configured via timeout_ms) expires.
|
||||
// This is passed through as a negative return value, which callers treat as an error.
|
||||
int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
|
||||
const uint32_t start = millis();
|
||||
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
|
||||
esp_http_client_set_timeout_ms(this->client_, this->timeout_ms_);
|
||||
return this->read_(buf, max_len);
|
||||
}
|
||||
|
||||
int HttpContainerIDF::read_available(uint8_t *buf, size_t max_len) {
|
||||
// A zero timeout only polls; TLS still waits for the rest of a record that has begun to arrive
|
||||
watchdog::WatchdogManager wdm(this->get_watchdog_timeout());
|
||||
esp_http_client_set_timeout_ms(this->client_, 0);
|
||||
int read_len_or_error = this->read_(buf, max_len);
|
||||
return read_len_or_error == -ESP_ERR_HTTP_EAGAIN ? 0 : read_len_or_error;
|
||||
}
|
||||
|
||||
int HttpContainerIDF::read_(uint8_t *buf, size_t max_len) {
|
||||
const uint32_t start = millis();
|
||||
|
||||
// Check if we've already read all expected content (non-chunked and no-body only).
|
||||
// Use the base class check here, NOT the override: esp_http_client_is_complete_data_received()
|
||||
|
||||
@@ -5,30 +5,71 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <esp_event.h>
|
||||
#include <esp_idf_version.h>
|
||||
#include <esp_http_client.h>
|
||||
#include <esp_netif.h>
|
||||
#include <esp_tls.h>
|
||||
|
||||
// The socket is needed to wait for the response without blocking; esp_http_client_get_socket() is new in 5.5.3
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 3)
|
||||
#define USE_HTTP_REQUEST_IDF_SOCKET_POLL
|
||||
#endif
|
||||
|
||||
namespace esphome::http_request {
|
||||
|
||||
class HttpRequestIDF;
|
||||
|
||||
class HttpContainerIDF : public HttpContainer {
|
||||
public:
|
||||
HttpContainerIDF(esp_http_client_handle_t client) : client_(client) {}
|
||||
int read(uint8_t *buf, size_t max_len) override;
|
||||
int read_available(uint8_t *buf, size_t max_len) override;
|
||||
void end() override;
|
||||
bool is_read_complete() const override;
|
||||
HttpRequestProgress poll() override;
|
||||
|
||||
/// @brief Feeds the watchdog timer if the executing task has one attached
|
||||
void feed_wdt();
|
||||
|
||||
protected:
|
||||
friend class HttpRequestIDF;
|
||||
enum class Stage : uint8_t {
|
||||
STAGE_CONNECT,
|
||||
STAGE_SEND_BODY,
|
||||
STAGE_HEADERS,
|
||||
STAGE_DONE,
|
||||
STAGE_FAILED,
|
||||
};
|
||||
|
||||
int read_(uint8_t *buf, size_t max_len);
|
||||
/// True when the socket can be read or written without waiting; always true when it cannot be checked
|
||||
bool socket_ready_(bool write);
|
||||
HttpRequestProgress wait_();
|
||||
HttpRequestProgress fail_(esp_err_t err);
|
||||
/// Finish with an unsuccessful status, which is reported through on_response like a success
|
||||
HttpRequestProgress response_failed_(int status_code);
|
||||
HttpRequestIDF *idf_parent_();
|
||||
|
||||
esp_http_client_handle_t client_;
|
||||
// Owned copy (not a reference): must outlive perform() for the response-header event handler
|
||||
std::vector<std::string> collect_headers_;
|
||||
// Request body for begin(), released once sent
|
||||
std::string body_;
|
||||
size_t body_sent_{0};
|
||||
uint32_t timeout_ms_{0};
|
||||
// Time of the last step forward, for the request timeout of begin()
|
||||
uint32_t progress_time_{0};
|
||||
uint32_t start_time_{0};
|
||||
uint16_t redirects_left_{0};
|
||||
Stage stage_{Stage::STAGE_DONE};
|
||||
// An esp_http_client_open() of a non-blocking client returned ESP_ERR_HTTP_EAGAIN
|
||||
bool connecting_{false};
|
||||
bool async_{false};
|
||||
};
|
||||
|
||||
class HttpRequestIDF final : public HttpRequestComponent {
|
||||
friend class HttpContainerIDF;
|
||||
|
||||
public:
|
||||
// User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero.
|
||||
HttpRequestIDF() {}
|
||||
@@ -40,7 +81,21 @@ class HttpRequestIDF final : public HttpRequestComponent {
|
||||
void set_verify_ssl(bool verify_ssl) { this->verify_ssl_ = verify_ssl; }
|
||||
void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; }
|
||||
|
||||
std::shared_ptr<HttpContainer> begin(const char *url, const char *method, std::string &&body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms) override;
|
||||
|
||||
protected:
|
||||
/// A container that poll() takes from the connect through to the response headers
|
||||
std::shared_ptr<HttpContainerIDF> start_container_(const char *url, const char *method, std::string &&body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms, bool async);
|
||||
std::shared_ptr<HttpContainerIDF> create_container_(const char *url, const char *method,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
uint32_t timeout_ms, bool async);
|
||||
std::shared_ptr<HttpContainer> perform(const char *url, const char *method, const std::string &body,
|
||||
const std::vector<Header> &request_headers,
|
||||
const std::vector<std::string> &lower_case_collect_headers,
|
||||
|
||||
@@ -729,7 +729,6 @@ CONF_DISP_BG_COLOR = "disp_bg_color"
|
||||
CONF_DISP_BG_IMAGE = "disp_bg_image"
|
||||
CONF_DISP_BG_OPA = "disp_bg_opa"
|
||||
CONF_BODY = "body"
|
||||
CONF_BUTTONS = "buttons"
|
||||
CONF_CHANGE_RATE = "change_rate"
|
||||
CONF_CLICKABLE = "clickable"
|
||||
CONF_CLOSE_BUTTON = "close_button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ROWS
|
||||
from esphome.components.const import CONF_BUTTONS, CONF_ROWS
|
||||
from esphome.components.key_provider import KeyProvider
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH
|
||||
@@ -10,7 +10,6 @@ from esphome.types import Expression
|
||||
from ..automation import action_to_code
|
||||
from ..defines import (
|
||||
BUTTONMATRIX_CTRLS,
|
||||
CONF_BUTTONS,
|
||||
CONF_CONTROL,
|
||||
CONF_KEY_CODE,
|
||||
CONF_MAIN,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from esphome import codegen as cg, config_validation as cv
|
||||
from esphome.components.const import CONF_BUTTONS
|
||||
from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
@@ -7,7 +8,6 @@ from esphome.cpp_types import nullptr
|
||||
from ..defines import (
|
||||
CONF_BODY,
|
||||
CONF_BUTTON_STYLE,
|
||||
CONF_BUTTONS,
|
||||
CONF_CLOSE_BUTTON,
|
||||
CONF_HEADER_BUTTONS,
|
||||
CONF_MAIN,
|
||||
|
||||
@@ -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")],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user