mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 00:31:58 +00:00
Merge branch 'ard_chunked_http_request' into integration
This commit is contained in:
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
|
||||
uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -86,6 +86,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
|
||||
uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -294,8 +294,13 @@ def has_api() -> bool:
|
||||
|
||||
|
||||
def has_ota() -> bool:
|
||||
"""Check if OTA is available."""
|
||||
return CONF_OTA in CORE.config
|
||||
"""Check if OTA upload is available (requires platform: esphome)."""
|
||||
if CONF_OTA not in CORE.config:
|
||||
return False
|
||||
return any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
for ota_item in CORE.config[CONF_OTA]
|
||||
)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
|
||||
@@ -17,3 +17,9 @@ CONF_ON_STATE_CHANGE = "on_state_change"
|
||||
CONF_REQUEST_HEADERS = "request_headers"
|
||||
CONF_ROWS = "rows"
|
||||
CONF_USE_PSRAM = "use_psram"
|
||||
|
||||
ICON_CURRENT_DC = "mdi:current-dc"
|
||||
ICON_SOLAR_PANEL = "mdi:solar-panel"
|
||||
ICON_SOLAR_POWER = "mdi:solar-power"
|
||||
|
||||
UNIT_AMPERE_HOUR = "Ah"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
from esphome.components.const import ICON_CURRENT_DC, UNIT_AMPERE_HOUR
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_LEVEL,
|
||||
@@ -55,14 +56,11 @@ CONF_CELL_15_VOLTAGE = "cell_15_voltage"
|
||||
CONF_CELL_16_VOLTAGE = "cell_16_voltage"
|
||||
CONF_CELL_17_VOLTAGE = "cell_17_voltage"
|
||||
CONF_CELL_18_VOLTAGE = "cell_18_voltage"
|
||||
ICON_CURRENT_DC = "mdi:current-dc"
|
||||
ICON_BATTERY_OUTLINE = "mdi:battery-outline"
|
||||
ICON_THERMOMETER_CHEVRON_UP = "mdi:thermometer-chevron-up"
|
||||
ICON_THERMOMETER_CHEVRON_DOWN = "mdi:thermometer-chevron-down"
|
||||
ICON_CAR_BATTERY = "mdi:car-battery"
|
||||
|
||||
UNIT_AMPERE_HOUR = "Ah"
|
||||
|
||||
TYPES = [
|
||||
CONF_VOLTAGE,
|
||||
CONF_CURRENT,
|
||||
|
||||
@@ -1467,7 +1467,7 @@ async def to_code(config):
|
||||
[_format_framework_espidf_version(idf_ver, None)],
|
||||
)
|
||||
# Use stub package to skip downloading precompiled libs
|
||||
stubs_dir = CORE.relative_build_path("arduino-libs-stub")
|
||||
stubs_dir = CORE.relative_build_path("arduino_libs_stub")
|
||||
cg.add_platformio_option(
|
||||
"platform_packages", [f"{ARDUINO_LIBS_PKG}@file://{stubs_dir}"]
|
||||
)
|
||||
|
||||
@@ -133,20 +133,10 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
|
||||
|
||||
// HTTPClient::getSize() returns -1 for chunked transfer encoding (no Content-Length).
|
||||
// When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit).
|
||||
// The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the
|
||||
// early return check (bytes_read_ >= content_length) will never trigger.
|
||||
//
|
||||
// TODO: Chunked transfer encoding is NOT properly supported on Arduino.
|
||||
// The implementation in #7884 was incomplete - it only works correctly on ESP-IDF where
|
||||
// esp_http_client_read() decodes chunks internally. On Arduino, using getStreamPtr()
|
||||
// returns raw TCP data with chunk framing (e.g., "12a\r\n{json}\r\n0\r\n\r\n") instead
|
||||
// of decoded content. This wasn't noticed because requests would complete and payloads
|
||||
// were only examined on IDF. The long transfer times were also masked by the misleading
|
||||
// "HTTP on Arduino version >= 3.1 is **very** slow" warning above. This causes two issues:
|
||||
// 1. Response body is corrupted - contains chunk size headers mixed with data
|
||||
// 2. Cannot detect end of transfer - connection stays open (keep-alive), causing timeout
|
||||
// The proper fix would be to use getString() for chunked responses, which decodes chunks
|
||||
// internally, but this buffers the entire response in memory.
|
||||
// The read() method uses a chunked transfer encoding decoder (read_chunked_) to strip
|
||||
// chunk framing and deliver only decoded content. When the final 0-size chunk is received,
|
||||
// is_chunked_ is cleared and content_length is set to the actual decoded size, so
|
||||
// is_read_complete() returns true and callers exit their read loops correctly.
|
||||
int content_length = container->client_.getSize();
|
||||
ESP_LOGD(TAG, "Content-Length: %d", content_length);
|
||||
container->content_length = (size_t) content_length;
|
||||
@@ -174,6 +164,10 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
|
||||
// > 0: bytes read
|
||||
// 0: no data yet, retry <-- NOTE: 0 means retry, NOT EOF!
|
||||
// < 0: error/connection closed <-- connection closed returns -1, not 0
|
||||
//
|
||||
// For chunked transfer encoding, read_chunked_() decodes chunk framing and delivers
|
||||
// only the payload data. When the final 0-size chunk is received, it clears is_chunked_
|
||||
// and sets content_length = bytes_read_ so is_read_complete() returns true.
|
||||
int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
|
||||
const uint32_t start = millis();
|
||||
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
|
||||
@@ -184,24 +178,42 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
|
||||
return HTTP_ERROR_CONNECTION_CLOSED;
|
||||
}
|
||||
|
||||
if (this->is_chunked_) {
|
||||
int result = this->read_chunked_(buf, max_len, stream_ptr);
|
||||
this->duration_ms += (millis() - start);
|
||||
if (result > 0) {
|
||||
return result;
|
||||
}
|
||||
// result <= 0: check for completion or errors
|
||||
if (this->is_read_complete()) {
|
||||
return 0; // Chunked transfer complete (final 0-size chunk received)
|
||||
}
|
||||
if (result < 0) {
|
||||
return result; // Stream error during chunk decoding
|
||||
}
|
||||
// read_chunked_ returned 0: no data was available (available() was 0).
|
||||
// This happens when the TCP buffer is empty - either more data is in flight,
|
||||
// or the connection dropped. Arduino's connected() returns false only when
|
||||
// both the remote has closed AND the receive buffer is empty, so any buffered
|
||||
// data is fully drained before we report the drop.
|
||||
if (!stream_ptr->connected()) {
|
||||
return HTTP_ERROR_CONNECTION_CLOSED;
|
||||
}
|
||||
return 0; // No data yet, caller should retry
|
||||
}
|
||||
|
||||
// Non-chunked path
|
||||
int available_data = stream_ptr->available();
|
||||
// For chunked transfer encoding, HTTPClient::getSize() returns -1, which becomes SIZE_MAX when
|
||||
// cast to size_t. SIZE_MAX - bytes_read_ is still huge, so it won't limit the read.
|
||||
size_t remaining = (this->content_length > 0) ? (this->content_length - this->bytes_read_) : max_len;
|
||||
int bufsize = std::min(max_len, std::min(remaining, (size_t) available_data));
|
||||
|
||||
if (bufsize == 0) {
|
||||
this->duration_ms += (millis() - start);
|
||||
// Check if we've read all expected content (non-chunked only)
|
||||
// For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false
|
||||
if (this->is_read_complete()) {
|
||||
return 0; // All content read successfully
|
||||
}
|
||||
// No data available - check if connection is still open
|
||||
// For chunked encoding, !connected() after reading means EOF (all chunks received)
|
||||
// For known content_length with bytes_read_ < content_length, it means connection dropped
|
||||
if (!stream_ptr->connected()) {
|
||||
return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed or EOF for chunked
|
||||
return HTTP_ERROR_CONNECTION_CLOSED;
|
||||
}
|
||||
return 0; // No data yet, caller should retry
|
||||
}
|
||||
@@ -215,6 +227,113 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
|
||||
return read_len;
|
||||
}
|
||||
|
||||
// Chunked transfer encoding decoder
|
||||
//
|
||||
// On Arduino, getStreamPtr() returns raw TCP data. For chunked responses, this includes
|
||||
// chunk framing (size headers, CRLF delimiters) mixed with payload data. This decoder
|
||||
// strips the framing and delivers only decoded content to the caller.
|
||||
//
|
||||
// Chunk format (RFC 9112 Section 7.1):
|
||||
// <hex-size>[;extension]\r\n
|
||||
// <data bytes>\r\n
|
||||
// ...
|
||||
// 0\r\n
|
||||
// \r\n
|
||||
//
|
||||
// Non-blocking: only processes bytes already in the TCP receive buffer.
|
||||
// State (chunk_state_, chunk_remaining_) is preserved between calls, so partial
|
||||
// chunk headers or split \r\n sequences resume correctly on the next call.
|
||||
// Framing bytes (hex sizes, \r\n) may be consumed without producing output;
|
||||
// the caller sees 0 and retries via the normal read timeout logic.
|
||||
//
|
||||
// WiFiClient::read() returns -1 on error despite available() > 0 (connection reset
|
||||
// between check and read). On any stream error (c < 0 or readBytes <= 0), we return
|
||||
// already-decoded data if any; otherwise HTTP_ERROR_CONNECTION_CLOSED. The error
|
||||
// will surface again on the next call since the stream stays broken.
|
||||
//
|
||||
// Returns: > 0 decoded bytes, 0 no data available, < 0 error
|
||||
int HttpContainerArduino::read_chunked_(uint8_t *buf, size_t max_len, WiFiClient *stream) {
|
||||
int total_decoded = 0;
|
||||
|
||||
while (total_decoded < (int) max_len && this->chunk_state_ != ChunkedState::COMPLETE) {
|
||||
// Non-blocking: only process what's already buffered
|
||||
if (stream->available() == 0)
|
||||
break;
|
||||
|
||||
int c;
|
||||
switch (this->chunk_state_) {
|
||||
// Parse hex chunk size, one byte at a time: "<hex>[;ext]\r\n"
|
||||
case ChunkedState::CHUNK_HEADER:
|
||||
c = stream->read();
|
||||
if (c < 0)
|
||||
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
|
||||
if (c == '\n') {
|
||||
// \n terminates the size line; chunk_remaining_ == 0 means last chunk
|
||||
this->chunk_state_ = this->chunk_remaining_ == 0 ? ChunkedState::COMPLETE : ChunkedState::CHUNK_DATA;
|
||||
} else {
|
||||
uint8_t hex = parse_hex_char(c);
|
||||
if (hex != INVALID_HEX_CHAR)
|
||||
this->chunk_remaining_ = (this->chunk_remaining_ << 4) | hex;
|
||||
else if (c != '\r')
|
||||
this->chunk_state_ = ChunkedState::CHUNK_HEADER_EXT; // ';' starts extension, skip to \n
|
||||
}
|
||||
break;
|
||||
|
||||
// Skip chunk extension bytes until \n (e.g., ";name=value\r\n")
|
||||
case ChunkedState::CHUNK_HEADER_EXT:
|
||||
c = stream->read();
|
||||
if (c < 0)
|
||||
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
|
||||
if (c == '\n')
|
||||
this->chunk_state_ = this->chunk_remaining_ == 0 ? ChunkedState::COMPLETE : ChunkedState::CHUNK_DATA;
|
||||
break;
|
||||
|
||||
// Read decoded payload bytes into caller's buffer
|
||||
case ChunkedState::CHUNK_DATA: {
|
||||
// Only read what's available, what fits in buf, and what remains in this chunk
|
||||
size_t to_read =
|
||||
std::min({max_len - (size_t) total_decoded, this->chunk_remaining_, (size_t) stream->available()});
|
||||
if (to_read == 0)
|
||||
break;
|
||||
App.feed_wdt();
|
||||
int read_len = stream->readBytes(buf + total_decoded, to_read);
|
||||
if (read_len <= 0)
|
||||
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
|
||||
total_decoded += read_len;
|
||||
this->chunk_remaining_ -= read_len;
|
||||
this->bytes_read_ += read_len;
|
||||
if (this->chunk_remaining_ == 0)
|
||||
this->chunk_state_ = ChunkedState::CHUNK_DATA_TRAIL;
|
||||
break;
|
||||
}
|
||||
|
||||
// Consume \r\n trailing each chunk's data
|
||||
case ChunkedState::CHUNK_DATA_TRAIL:
|
||||
c = stream->read();
|
||||
if (c < 0)
|
||||
return total_decoded > 0 ? total_decoded : HTTP_ERROR_CONNECTION_CLOSED;
|
||||
if (c == '\n') {
|
||||
this->chunk_state_ = ChunkedState::CHUNK_HEADER;
|
||||
this->chunk_remaining_ = 0; // reset for next chunk's hex accumulation
|
||||
}
|
||||
// else: \r is consumed silently, next iteration gets \n
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->chunk_state_ == ChunkedState::COMPLETE) {
|
||||
// Clear chunked flag and set content_length to actual decoded size so
|
||||
// is_read_complete() returns true and callers exit their read loops
|
||||
this->is_chunked_ = false;
|
||||
this->content_length = this->bytes_read_;
|
||||
}
|
||||
}
|
||||
|
||||
return total_decoded;
|
||||
}
|
||||
|
||||
void HttpContainerArduino::end() {
|
||||
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
|
||||
this->client_.end();
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
namespace esphome::http_request {
|
||||
|
||||
class HttpRequestArduino;
|
||||
|
||||
/// State machine for decoding chunked transfer encoding on Arduino
|
||||
enum class ChunkedState : uint8_t {
|
||||
CHUNK_HEADER, ///< Reading hex digits of chunk size
|
||||
CHUNK_HEADER_EXT, ///< Skipping chunk extensions until \n
|
||||
CHUNK_DATA, ///< Reading chunk data bytes
|
||||
CHUNK_DATA_TRAIL, ///< Skipping \r\n after chunk data
|
||||
COMPLETE, ///< Received final 0-size chunk
|
||||
};
|
||||
|
||||
class HttpContainerArduino : public HttpContainer {
|
||||
public:
|
||||
int read(uint8_t *buf, size_t max_len) override;
|
||||
@@ -26,6 +36,11 @@ class HttpContainerArduino : public HttpContainer {
|
||||
protected:
|
||||
friend class HttpRequestArduino;
|
||||
HTTPClient client_{};
|
||||
|
||||
/// Decode chunked transfer encoding from the raw stream
|
||||
int read_chunked_(uint8_t *buf, size_t max_len, WiFiClient *stream);
|
||||
ChunkedState chunk_state_{ChunkedState::CHUNK_HEADER};
|
||||
size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk
|
||||
};
|
||||
|
||||
class HttpRequestArduino : public HttpRequestComponent {
|
||||
|
||||
@@ -133,8 +133,10 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout, container->is_read_complete());
|
||||
if (result == HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
|
||||
// but this is defensive code in case chunked transfer encoding support is added for OTA in the future.
|
||||
// For non-chunked responses, COMPLETE is unreachable (loop condition checks bytes_read < content_length).
|
||||
// For chunked responses, the decoder sets content_length = bytes_read when the final chunk arrives,
|
||||
// which causes the loop condition to terminate. But COMPLETE can still be returned if the decoder
|
||||
// finishes mid-read, so this is needed for correctness.
|
||||
if (result == HttpReadLoopResult::COMPLETE)
|
||||
break;
|
||||
if (result != HttpReadLoopResult::DATA) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
from esphome.components.const import UNIT_AMPERE_HOUR
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUS_VOLTAGE,
|
||||
@@ -36,7 +37,6 @@ CONF_CHARGE_COULOMBS = "charge_coulombs"
|
||||
CONF_ENERGY_JOULES = "energy_joules"
|
||||
CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient"
|
||||
CONF_RESET_ON_BOOT = "reset_on_boot"
|
||||
UNIT_AMPERE_HOURS = "Ah"
|
||||
UNIT_COULOMB = "C"
|
||||
UNIT_JOULE = "J"
|
||||
UNIT_MILLIVOLT = "mV"
|
||||
@@ -180,7 +180,7 @@ INA2XX_SCHEMA = cv.Schema(
|
||||
),
|
||||
cv.Optional(CONF_CHARGE): cv.maybe_simple_value(
|
||||
sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_AMPERE_HOURS,
|
||||
unit_of_measurement=UNIT_AMPERE_HOUR,
|
||||
accuracy_decimals=8,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
from esphome.components.const import ICON_CURRENT_DC, ICON_SOLAR_PANEL, ICON_SOLAR_POWER
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_VOLTAGE,
|
||||
@@ -29,9 +30,6 @@ from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
ICON_SOLAR_POWER = "mdi:solar-power"
|
||||
ICON_SOLAR_PANEL = "mdi:solar-panel"
|
||||
ICON_CURRENT_DC = "mdi:current-dc"
|
||||
|
||||
# QPIRI sensors
|
||||
CONF_GRID_RATING_VOLTAGE = "grid_rating_voltage"
|
||||
|
||||
@@ -34,6 +34,8 @@ void rdm6300::RDM6300Component::loop() {
|
||||
this->buffer_[this->read_state_ / 2] += value;
|
||||
}
|
||||
this->read_state_++;
|
||||
} else if (data == 0x0D || data == 0x0A) {
|
||||
// Skip CR/LF bytes (ID-20LA compatibility)
|
||||
} else if (data != RDM6300_END_BYTE) {
|
||||
ESP_LOGW(TAG, "Invalid end byte from RDM6300!");
|
||||
this->read_state_ = RDM6300_STATE_WAITING_FOR_START;
|
||||
|
||||
@@ -296,7 +296,7 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) {
|
||||
size_t chars = std::min(length, 2 * count);
|
||||
for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) {
|
||||
uint8_t val = parse_hex_char(*str);
|
||||
if (val > 15)
|
||||
if (val == INVALID_HEX_CHAR)
|
||||
return 0;
|
||||
data[i >> 1] = (i & 1) ? data[i >> 1] | val : val << 4;
|
||||
}
|
||||
|
||||
@@ -949,6 +949,9 @@ template<typename T, enable_if_t<std::is_unsigned<T>::value, int> = 0> optional<
|
||||
}
|
||||
|
||||
/// Parse a hex character to its nibble value (0-15), returns 255 on invalid input
|
||||
/// Returned by parse_hex_char() for non-hex characters.
|
||||
static constexpr uint8_t INVALID_HEX_CHAR = 255;
|
||||
|
||||
constexpr uint8_t parse_hex_char(char c) {
|
||||
if (c >= '0' && c <= '9')
|
||||
return c - '0';
|
||||
@@ -956,7 +959,7 @@ constexpr uint8_t parse_hex_char(char c) {
|
||||
return c - 'A' + 10;
|
||||
if (c >= 'a' && c <= 'f')
|
||||
return c - 'a' + 10;
|
||||
return 255;
|
||||
return INVALID_HEX_CHAR;
|
||||
}
|
||||
|
||||
/// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase)
|
||||
|
||||
@@ -32,6 +32,7 @@ from esphome.__main__ import (
|
||||
has_mqtt_ip_lookup,
|
||||
has_mqtt_logging,
|
||||
has_non_ip_address,
|
||||
has_ota,
|
||||
has_resolvable_address,
|
||||
mqtt_get_ip,
|
||||
run_esphome,
|
||||
@@ -332,7 +333,9 @@ def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None:
|
||||
|
||||
def test_choose_upload_log_host_with_ota_list() -> None:
|
||||
"""Test with OTA as the only item in the list."""
|
||||
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
|
||||
)
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default=["OTA"],
|
||||
@@ -345,7 +348,7 @@ def test_choose_upload_log_host_with_ota_list() -> None:
|
||||
@pytest.mark.usefixtures("mock_has_mqtt_logging")
|
||||
def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None:
|
||||
"""Test with OTA list falling back to MQTT when no address."""
|
||||
setup_core(config={CONF_OTA: {}, "mqtt": {}})
|
||||
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], "mqtt": {}})
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default=["OTA"],
|
||||
@@ -408,7 +411,9 @@ def test_choose_upload_log_host_with_serial_device_with_ports(
|
||||
|
||||
def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
|
||||
"""Test OTA device when OTA is configured."""
|
||||
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
|
||||
)
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default="OTA",
|
||||
@@ -475,7 +480,9 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
|
||||
@pytest.mark.usefixtures("mock_choose_prompt")
|
||||
def test_choose_upload_log_host_multiple_devices() -> None:
|
||||
"""Test with multiple devices including special identifiers."""
|
||||
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
|
||||
)
|
||||
|
||||
mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")]
|
||||
|
||||
@@ -514,7 +521,9 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports(
|
||||
@pytest.mark.usefixtures("mock_no_serial_ports")
|
||||
def test_choose_upload_log_host_no_defaults_with_ota() -> None:
|
||||
"""Test interactive mode with OTA option."""
|
||||
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"esphome.__main__.choose_prompt", return_value="192.168.1.100"
|
||||
@@ -575,7 +584,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options(
|
||||
) -> None:
|
||||
"""Test interactive mode with all options available."""
|
||||
setup_core(
|
||||
config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}},
|
||||
config={
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_API: {},
|
||||
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
|
||||
},
|
||||
address="192.168.1.100",
|
||||
)
|
||||
|
||||
@@ -604,7 +617,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
|
||||
) -> None:
|
||||
"""Test interactive mode with all options available."""
|
||||
setup_core(
|
||||
config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}},
|
||||
config={
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_API: {},
|
||||
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
|
||||
},
|
||||
address="192.168.1.100",
|
||||
)
|
||||
|
||||
@@ -632,7 +649,9 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
|
||||
@pytest.mark.usefixtures("mock_no_serial_ports")
|
||||
def test_choose_upload_log_host_check_default_matches() -> None:
|
||||
"""Test when check_default matches an available option."""
|
||||
setup_core(config={CONF_OTA: {}}, address="192.168.1.100")
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
|
||||
)
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default=None,
|
||||
@@ -704,7 +723,10 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None:
|
||||
|
||||
def test_choose_upload_log_host_ota_both_conditions() -> None:
|
||||
"""Test OTA device when both OTA and API are configured and enabled."""
|
||||
setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100")
|
||||
setup_core(
|
||||
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}], CONF_API: {}},
|
||||
address="192.168.1.100",
|
||||
)
|
||||
|
||||
result = choose_upload_log_host(
|
||||
default="OTA",
|
||||
@@ -719,7 +741,7 @@ def test_choose_upload_log_host_ota_ip_all_options() -> None:
|
||||
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: {},
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_API: {},
|
||||
CONF_MQTT: {
|
||||
CONF_BROKER: "mqtt.local",
|
||||
@@ -744,7 +766,7 @@ def test_choose_upload_log_host_ota_local_all_options() -> None:
|
||||
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: {},
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_API: {},
|
||||
CONF_MQTT: {
|
||||
CONF_BROKER: "mqtt.local",
|
||||
@@ -769,7 +791,7 @@ def test_choose_upload_log_host_ota_ip_all_options_logging() -> None:
|
||||
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: {},
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_API: {},
|
||||
CONF_MQTT: {
|
||||
CONF_BROKER: "mqtt.local",
|
||||
@@ -794,7 +816,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
|
||||
"""Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: {},
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_API: {},
|
||||
CONF_MQTT: {
|
||||
CONF_BROKER: "mqtt.local",
|
||||
@@ -817,7 +839,7 @@ def test_choose_upload_log_host_ota_local_all_options_logging() -> None:
|
||||
@pytest.mark.usefixtures("mock_no_mqtt_logging")
|
||||
def test_choose_upload_log_host_no_address_with_ota_config() -> None:
|
||||
"""Test OTA device when OTA is configured but no address is set."""
|
||||
setup_core(config={CONF_OTA: {}})
|
||||
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
|
||||
|
||||
with pytest.raises(
|
||||
EsphomeError, match="All specified devices .* could not be resolved"
|
||||
@@ -1532,10 +1554,43 @@ def test_has_mqtt() -> None:
|
||||
assert has_mqtt() is False
|
||||
|
||||
# Test with other components but no MQTT
|
||||
setup_core(config={CONF_API: {}, CONF_OTA: {}})
|
||||
setup_core(config={CONF_API: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
|
||||
assert has_mqtt() is False
|
||||
|
||||
|
||||
def test_has_ota() -> None:
|
||||
"""Test has_ota function.
|
||||
|
||||
The has_ota function should only return True when OTA is configured
|
||||
with platform: esphome, not when only platform: http_request is configured.
|
||||
This is because CLI OTA upload only works with the esphome platform.
|
||||
"""
|
||||
# Test with OTA esphome platform configured
|
||||
setup_core(config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]})
|
||||
assert has_ota() is True
|
||||
|
||||
# Test with OTA http_request platform only (should return False)
|
||||
# This is the bug scenario from issue #13783
|
||||
setup_core(config={CONF_OTA: [{CONF_PLATFORM: "http_request"}]})
|
||||
assert has_ota() is False
|
||||
|
||||
# Test without OTA configured
|
||||
setup_core(config={})
|
||||
assert has_ota() is False
|
||||
|
||||
# Test with multiple OTA platforms including esphome
|
||||
setup_core(
|
||||
config={
|
||||
CONF_OTA: [{CONF_PLATFORM: "http_request"}, {CONF_PLATFORM: CONF_ESPHOME}]
|
||||
}
|
||||
)
|
||||
assert has_ota() is True
|
||||
|
||||
# Test with empty OTA list
|
||||
setup_core(config={CONF_OTA: []})
|
||||
assert has_ota() is False
|
||||
|
||||
|
||||
def test_get_port_type() -> None:
|
||||
"""Test get_port_type function."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user