Compare commits

...

10 Commits

Author SHA1 Message Date
Jesse Hills
fb2f0ce62f Merge pull request #13915 from esphome/bump-2026.1.5
2026.1.5
2026-02-11 11:13:08 +13:00
Jesse Hills
a99f75ca71 Bump version to 2026.1.5 2026-02-11 08:45:06 +13:00
Sean Kelly
4168e8c30d [aqi] Fix AQI calculation for specific pm2.5 or pm10 readings (#13770) 2026-02-11 08:45:06 +13:00
Jonathan Swoboda
1f761902b6 [esp32] Set UV_CACHE_DIR inside data dir so Clean All clears it (#13888)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 07:48:20 +13:00
Clyde Stubbs
0b047c334d [lvgl] Fix crash with unconfigured top_layer (#13846) 2026-02-11 07:24:32 +13:00
tomaszduda23
a5dc4b0fce [nrf52,logger] fix printk (#13874) 2026-02-11 07:24:32 +13:00
J. Nick Koston
c1455ccc29 [dashboard] Close WebSocket after process exit to prevent zombie connections (#13834) 2026-02-11 07:24:32 +13:00
Jonathan Swoboda
438a0c4289 [ota] Fix CLI upload option shown when only http_request platform configured (#13784)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 07:24:32 +13:00
Jonathan Swoboda
9eee4c9924 [core] Add capacity check to register_component_ (#13778)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 07:24:32 +13:00
Jas Strong
eea7e9edff [rd03d] Revert incorrect field order swap (#13769)
Co-authored-by: jas <jas@asspa.in>
2026-02-11 07:24:32 +13:00
14 changed files with 176 additions and 41 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.1.4 PROJECT_NUMBER = 2026.1.5
# Using the PROJECT_BRIEF tag one can provide an optional one line description # Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a # for a project that appears at the top of each page and should give viewer a

View File

@@ -287,8 +287,13 @@ def has_api() -> bool:
def has_ota() -> bool: def has_ota() -> bool:
"""Check if OTA is available.""" """Check if OTA upload is available (requires platform: esphome)."""
return CONF_OTA in CORE.config 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: def has_mqtt_ip_lookup() -> bool:

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include "abstract_aqi_calculator.h" #include "abstract_aqi_calculator.h"
@@ -14,7 +15,11 @@ class AQICalculator : public AbstractAQICalculator {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return static_cast<uint16_t>(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index)); float aqi = std::max(pm2_5_index, pm10_0_index);
if (aqi < 0.0f) {
aqi = 0.0f;
}
return static_cast<uint16_t>(std::lround(aqi));
} }
protected: protected:
@@ -22,13 +27,27 @@ class AQICalculator : public AbstractAQICalculator {
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}}; static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 50}, {51, 100}, {101, 150}, {151, 200}, {201, 300}, {301, 500}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {{0.0f, 9.0f}, {9.1f, 35.4f}, static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
{35.5f, 55.4f}, {55.5f, 125.4f}, // clang-format off
{125.5f, 225.4f}, {225.5f, std::numeric_limits<float>::max()}}; {0.0f, 9.1f},
{9.1f, 35.5f},
{35.5f, 55.5f},
{55.5f, 125.5f},
{125.5f, 225.5f},
{225.5f, std::numeric_limits<float>::max()}
// clang-format on
};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {{0.0f, 54.0f}, {55.0f, 154.0f}, static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
{155.0f, 254.0f}, {255.0f, 354.0f}, // clang-format off
{355.0f, 424.0f}, {425.0f, std::numeric_limits<float>::max()}}; {0.0f, 55.0f},
{55.0f, 155.0f},
{155.0f, 255.0f},
{255.0f, 355.0f},
{355.0f, 425.0f},
{425.0f, std::numeric_limits<float>::max()}
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) { static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array); int grid_index = get_grid_index(value, array);
@@ -45,7 +64,10 @@ class AQICalculator : public AbstractAQICalculator {
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) { for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) { const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
if (in_range) {
return i; return i;
} }
} }

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <algorithm>
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include "abstract_aqi_calculator.h" #include "abstract_aqi_calculator.h"
@@ -12,7 +13,11 @@ class CAQICalculator : public AbstractAQICalculator {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
return static_cast<uint16_t>(std::round((pm2_5_index < pm10_0_index) ? pm10_0_index : pm2_5_index)); float aqi = std::max(pm2_5_index, pm10_0_index);
if (aqi < 0.0f) {
aqi = 0.0f;
}
return static_cast<uint16_t>(std::lround(aqi));
} }
protected: protected:
@@ -21,10 +26,24 @@ class CAQICalculator : public AbstractAQICalculator {
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
{0.0f, 15.0f}, {15.1f, 30.0f}, {30.1f, 55.0f}, {55.1f, 110.0f}, {110.1f, std::numeric_limits<float>::max()}}; // clang-format off
{0.0f, 15.1f},
{15.1f, 30.1f},
{30.1f, 55.1f},
{55.1f, 110.1f},
{110.1f, std::numeric_limits<float>::max()}
// clang-format on
};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = { static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
{0.0f, 25.0f}, {25.1f, 50.0f}, {50.1f, 90.0f}, {90.1f, 180.0f}, {180.1f, std::numeric_limits<float>::max()}}; // clang-format off
{0.0f, 25.1f},
{25.1f, 50.1f},
{50.1f, 90.1f},
{90.1f, 180.1f},
{180.1f, std::numeric_limits<float>::max()}
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) { static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
int grid_index = get_grid_index(value, array); int grid_index = get_grid_index(value, array);
@@ -42,7 +61,10 @@ class CAQICalculator : public AbstractAQICalculator {
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) { for (int i = 0; i < NUM_LEVELS; i++) {
if (value >= array[i][0] && value <= array[i][1]) { const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
if (in_range) {
return i; return i;
} }
} }

View File

@@ -1026,6 +1026,10 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script", Path(__file__).parent / "iram_fix.py.script",
) )
# Set the uv cache inside the data dir so "Clean All" clears it.
# Avoids persistent corrupted cache from mid-stream download failures.
os.environ["UV_CACHE_DIR"] = str(CORE.relative_internal_path(".uv_cache"))
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_platformio_option("framework", "espidf") cg.add_platformio_option("framework", "espidf")
cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP_IDF")

View File

@@ -68,7 +68,7 @@ void HOT Logger::write_msg_(const char *msg, size_t len) {
#ifdef CONFIG_PRINTK #ifdef CONFIG_PRINTK
// Requires the debug component and an active SWD connection. // Requires the debug component and an active SWD connection.
// It is used for pyocd rtt -t nrf52840 // It is used for pyocd rtt -t nrf52840
k_str_out(const_cast<char *>(msg), len); printk("%.*s", static_cast<int>(len), msg);
#endif #endif
if (this->uart_dev_ == nullptr) { if (this->uart_dev_ == nullptr) {
return; return;

View File

@@ -436,6 +436,7 @@ def container_schema(widget_type: WidgetType, extras=None):
schema = schema.extend(widget_type.schema) schema = schema.extend(widget_type.schema)
def validator(value): def validator(value):
value = value or {}
return append_layout_schema(schema, value)(value) return append_layout_schema(schema, value)(value)
return validator return validator

View File

@@ -132,18 +132,15 @@ void RD03DComponent::process_frame_() {
// Header is 4 bytes, each target is 8 bytes // Header is 4 bytes, each target is 8 bytes
uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE); uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE);
// Extract raw bytes for this target // Extract raw bytes for this target (per datasheet Table 5-2: X, Y, Speed, Resolution)
// Note: Despite datasheet Table 5-2 showing order as X, Y, Speed, Resolution,
// actual radar output has Resolution before Speed (verified empirically -
// stationary targets were showing non-zero speed with original field order)
uint8_t x_low = this->buffer_[offset + 0]; uint8_t x_low = this->buffer_[offset + 0];
uint8_t x_high = this->buffer_[offset + 1]; uint8_t x_high = this->buffer_[offset + 1];
uint8_t y_low = this->buffer_[offset + 2]; uint8_t y_low = this->buffer_[offset + 2];
uint8_t y_high = this->buffer_[offset + 3]; uint8_t y_high = this->buffer_[offset + 3];
uint8_t res_low = this->buffer_[offset + 4]; uint8_t speed_low = this->buffer_[offset + 4];
uint8_t res_high = this->buffer_[offset + 5]; uint8_t speed_high = this->buffer_[offset + 5];
uint8_t speed_low = this->buffer_[offset + 6]; uint8_t res_low = this->buffer_[offset + 6];
uint8_t speed_high = this->buffer_[offset + 7]; uint8_t res_high = this->buffer_[offset + 7];
// Decode values per RD-03D format // Decode values per RD-03D format
int16_t x = decode_value(x_low, x_high); int16_t x = decode_value(x_low, x_high);

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum from esphome.enum import StrEnum
__version__ = "2026.1.4" __version__ = "2026.1.5"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = ( VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -81,6 +81,10 @@ void Application::register_component_(Component *comp) {
return; return;
} }
} }
if (this->components_.size() >= ESPHOME_COMPONENT_COUNT) {
ESP_LOGE(TAG, "Cannot register component %s - at capacity!", LOG_STR_ARG(comp->get_component_log_str()));
return;
}
this->components_.push_back(comp); this->components_.push_back(comp);
} }
void Application::setup() { void Application::setup() {

View File

@@ -317,6 +317,7 @@ class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandl
# Check if the proc was not forcibly closed # Check if the proc was not forcibly closed
_LOGGER.info("Process exited with return code %s", returncode) _LOGGER.info("Process exited with return code %s", returncode)
self.write_message({"event": "exit", "code": returncode}) self.write_message({"event": "exit", "code": returncode})
self.close()
def on_close(self) -> None: def on_close(self) -> None:
# Check if proc exists (if 'start' has been run) # Check if proc exists (if 'start' has been run)

View File

@@ -20,6 +20,8 @@ lvgl:
- id: lvgl_0 - id: lvgl_0
default_font: space16 default_font: space16
displays: sdl0 displays: sdl0
top_layer:
- id: lvgl_1 - id: lvgl_1
displays: sdl1 displays: sdl1
on_idle: on_idle:

View File

@@ -29,7 +29,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state, bool_to_entry_state,
) )
from esphome.dashboard.models import build_importable_device_dict from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.zeroconf import DiscoveredImport from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path from .common import get_fixture_path
@@ -1654,3 +1654,25 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state" assert data["event"] == "initial_state"
finally: finally:
ws.close() ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()

View File

@@ -32,6 +32,7 @@ from esphome.__main__ import (
has_mqtt_ip_lookup, has_mqtt_ip_lookup,
has_mqtt_logging, has_mqtt_logging,
has_non_ip_address, has_non_ip_address,
has_ota,
has_resolvable_address, has_resolvable_address,
mqtt_get_ip, mqtt_get_ip,
run_esphome, 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: def test_choose_upload_log_host_with_ota_list() -> None:
"""Test with OTA as the only item in the list.""" """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( result = choose_upload_log_host(
default=["OTA"], default=["OTA"],
@@ -345,7 +348,7 @@ def test_choose_upload_log_host_with_ota_list() -> None:
@pytest.mark.usefixtures("mock_has_mqtt_logging") @pytest.mark.usefixtures("mock_has_mqtt_logging")
def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None:
"""Test with OTA list falling back to MQTT when no address.""" """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( result = choose_upload_log_host(
default=["OTA"], 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: def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
"""Test OTA device when OTA is configured.""" """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( result = choose_upload_log_host(
default="OTA", default="OTA",
@@ -475,7 +480,9 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None:
@pytest.mark.usefixtures("mock_choose_prompt") @pytest.mark.usefixtures("mock_choose_prompt")
def test_choose_upload_log_host_multiple_devices() -> None: def test_choose_upload_log_host_multiple_devices() -> None:
"""Test with multiple devices including special identifiers.""" """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")] 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") @pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_no_defaults_with_ota() -> None: def test_choose_upload_log_host_no_defaults_with_ota() -> None:
"""Test interactive mode with OTA option.""" """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( with patch(
"esphome.__main__.choose_prompt", return_value="192.168.1.100" "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: ) -> None:
"""Test interactive mode with all options available.""" """Test interactive mode with all options available."""
setup_core( 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", address="192.168.1.100",
) )
@@ -604,7 +617,11 @@ def test_choose_upload_log_host_no_defaults_with_all_options_logging(
) -> None: ) -> None:
"""Test interactive mode with all options available.""" """Test interactive mode with all options available."""
setup_core( 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", 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") @pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_check_default_matches() -> None: def test_choose_upload_log_host_check_default_matches() -> None:
"""Test when check_default matches an available option.""" """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( result = choose_upload_log_host(
default=None, 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: def test_choose_upload_log_host_ota_both_conditions() -> None:
"""Test OTA device when both OTA and API are configured and enabled.""" """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( result = choose_upload_log_host(
default="OTA", 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.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", 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.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", 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.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", 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.""" """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not."""
setup_core( setup_core(
config={ config={
CONF_OTA: {}, CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_API: {}, CONF_API: {},
CONF_MQTT: { CONF_MQTT: {
CONF_BROKER: "mqtt.local", 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") @pytest.mark.usefixtures("mock_no_mqtt_logging")
def test_choose_upload_log_host_no_address_with_ota_config() -> None: def test_choose_upload_log_host_no_address_with_ota_config() -> None:
"""Test OTA device when OTA is configured but no address is set.""" """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( with pytest.raises(
EsphomeError, match="All specified devices .* could not be resolved" EsphomeError, match="All specified devices .* could not be resolved"
@@ -1532,10 +1554,43 @@ def test_has_mqtt() -> None:
assert has_mqtt() is False assert has_mqtt() is False
# Test with other components but no MQTT # 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 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: def test_get_port_type() -> None:
"""Test get_port_type function.""" """Test get_port_type function."""