1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-11 10:12:38 +00:00

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
# 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
# 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:
"""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:

View File

@@ -1,5 +1,6 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <limits>
#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 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:
@@ -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 float PM2_5_GRID[NUM_LEVELS][2] = {{0.0f, 9.0f}, {9.1f, 35.4f},
{35.5f, 55.4f}, {55.5f, 125.4f},
{125.5f, 225.4f}, {225.5f, std::numeric_limits<float>::max()}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
// clang-format off
{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},
{155.0f, 254.0f}, {255.0f, 354.0f},
{355.0f, 424.0f}, {425.0f, std::numeric_limits<float>::max()}};
static constexpr float PM10_0_GRID[NUM_LEVELS][2] = {
// clang-format off
{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]) {
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]) {
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;
}
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <limits>
#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 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:
@@ -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 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] = {
{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]) {
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]) {
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;
}
}

View File

@@ -1026,6 +1026,10 @@ async def to_code(config):
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:
cg.add_platformio_option("framework", "espidf")
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
// Requires the debug component and an active SWD connection.
// It is used for pyocd rtt -t nrf52840
k_str_out(const_cast<char *>(msg), len);
printk("%.*s", static_cast<int>(len), msg);
#endif
if (this->uart_dev_ == nullptr) {
return;

View File

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

View File

@@ -132,18 +132,15 @@ void RD03DComponent::process_frame_() {
// Header is 4 bytes, each target is 8 bytes
uint8_t offset = FRAME_HEADER_SIZE + (i * TARGET_DATA_SIZE);
// Extract raw bytes for this target
// 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)
// Extract raw bytes for this target (per datasheet Table 5-2: X, Y, Speed, Resolution)
uint8_t x_low = this->buffer_[offset + 0];
uint8_t x_high = this->buffer_[offset + 1];
uint8_t y_low = this->buffer_[offset + 2];
uint8_t y_high = this->buffer_[offset + 3];
uint8_t res_low = this->buffer_[offset + 4];
uint8_t res_high = this->buffer_[offset + 5];
uint8_t speed_low = this->buffer_[offset + 6];
uint8_t speed_high = this->buffer_[offset + 7];
uint8_t speed_low = this->buffer_[offset + 4];
uint8_t speed_high = this->buffer_[offset + 5];
uint8_t res_low = this->buffer_[offset + 6];
uint8_t res_high = this->buffer_[offset + 7];
// Decode values per RD-03D format
int16_t x = decode_value(x_low, x_high);

View File

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

View File

@@ -81,6 +81,10 @@ void Application::register_component_(Component *comp) {
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);
}
void Application::setup() {

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ from esphome.dashboard.entries import (
bool_to_entry_state,
)
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 .common import get_fixture_path
@@ -1654,3 +1654,25 @@ async def test_websocket_check_origin_multiple_trusted_domains(
assert data["event"] == "initial_state"
finally:
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_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."""