Compare commits

..

11 Commits

Author SHA1 Message Date
dependabot[bot] 69c6b3e38d Bump smpclient from 7.2.0 to 7.3.0
Bumps [smpclient](https://github.com/intercreate/smpclient) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/intercreate/smpclient/releases)
- [Commits](https://github.com/intercreate/smpclient/compare/7.2.0...7.3.0)

---
updated-dependencies:
- dependency-name: smpclient
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:22:49 +00:00
J. Nick Koston b4ad0eb86b [esp32_ble] Fix boot loop when the hosted co-processor does not answer BT bring-up (#17429) 2026-07-07 14:59:18 +00:00
Clyde Stubbs 7f0e826c32 [lvgl] Add paused option to suppress updates on boot (#16973)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-07 14:50:36 +00:00
Remco van Essen 76ee3fe887 [audio_file] Accept mp1/mp2 puremagic detections as MP3 (#17436)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 08:56:48 -04:00
Oliver Kleinecke af4a6e7ec3 [usb_uart] Fix FTDI RX data stall / corruption and input restart reliability (#17348)
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:55:49 +10:00
luar123 40c3a4320f [core] add const for litre per hour (#17389)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 21:44:44 +10:00
J. Nick Koston 3c2dad67f4 [network] Fix logged use_address with MAC suffix and build it at runtime (#17432) 2026-07-07 02:07:34 -05:00
Remco van Essen f823a23ea4 [pcm5122] Add analog gain, channel mixing, volume range, standby/powerdown switch, and XSMT enable pin support (#17313) 2026-07-07 00:15:37 -05:00
Keith Burzinski 9aed1d2700 [esp32] Add NVS encryption (HMAC scheme) (#17004) 2026-07-07 16:04:38 +12:00
Clyde Stubbs 9857d508d9 [light] Preserve brightness on turn-off. (#17103)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 15:57:06 +12:00
Clyde Stubbs ad7c980c4b [lvgl] Continue activity while display busy (#17374) 2026-07-07 03:16:29 +00:00
80 changed files with 1329 additions and 1516 deletions
+17 -45
View File
@@ -28,13 +28,13 @@ from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
CONF_API,
CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
CONF_DISABLED,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -48,7 +48,7 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
CONF_VERSION,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
@@ -317,12 +317,9 @@ def choose_upload_log_host(
]
resolved.append(choose_prompt(options, purpose=purpose))
elif device == "OTA":
# Logs can stream over a network transport via the native API
# or the web_server HTTP SSE feed.
network_logging = has_api() or has_web_server_logging()
# ensure IP adresses are used first
if is_ip_address(CORE.address) and (
(purpose == Purpose.LOGGING and network_logging)
(purpose == Purpose.LOGGING and has_api())
or (purpose == Purpose.UPLOADING and has_ota())
):
resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -334,11 +331,7 @@ def choose_upload_log_host(
if has_mqtt_logging():
resolved.append("MQTT")
if (
network_logging
and has_non_ip_address()
and has_resolvable_address()
):
if has_api() and has_non_ip_address() and has_resolvable_address():
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
@@ -400,7 +393,7 @@ def choose_upload_log_host(
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api() or has_web_server_logging():
if has_api():
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
@@ -493,21 +486,6 @@ def has_web_server_ota() -> bool:
)
def has_web_server_logging() -> bool:
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
The ``web_server`` component exposes a ``/events`` Server-Sent Events
stream that carries ``event: log`` frames. This requires version 2+ (the
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
"""
web_conf = CORE.config.get(CONF_WEB_SERVER)
if web_conf is None:
return False
if web_conf.get(CONF_VERSION, 2) == 1:
return False
return web_conf.get(CONF_LOG, True)
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
from esphome.components.mqtt import CONF_DISCOVER_IP
@@ -730,7 +708,6 @@ def _wrap_to_code(name, comp, yaml_util):
@functools.wraps(comp.to_code)
async def wrapped(conf):
cg.add(cg.ComponentMarker(name))
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
conf_str = yaml_util.dump(conf)
@@ -1314,23 +1291,25 @@ def _upload_via_native_api(
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
web_conf = config.get(CONF_WEB_SERVER)
if not web_conf:
raise EsphomeError(
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
f"is not configured."
)
remote_port = int(web_conf[CONF_PORT])
auth = web_conf.get(CONF_AUTH) or {}
username = auth.get(CONF_USERNAME)
password = auth.get(CONF_PASSWORD)
from esphome import web_server_ota
from esphome.web_server_helpers import get_web_server_connection
remote_port, username, password = get_web_server_connection(config)
return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary
)
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
from esphome import web_server_logs
from esphome.web_server_helpers import get_web_server_connection
port, username, password = get_web_server_connection(config)
return web_server_logs.run_logs(network_devices, port, username, password)
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
@@ -1459,13 +1438,6 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
config, args.topic, args.username, args.password, args.client_id
)
# Fall back to the web_server HTTP SSE log stream for devices that have
# web_server: but no api: (the logging counterpart to web_server OTA).
if has_web_server_logging() and (
network_devices := _resolve_network_devices(devices, config, args)
):
return _show_logs_via_web_server(config, network_devices)
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
+1 -2
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
import heapq
import json
from operator import itemgetter
from pathlib import Path
import sys
@@ -42,7 +41,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
# Symbol size threshold for detailed analysis
SYMBOL_SIZE_THRESHOLD: int = (
10 # Show symbols larger than this in detailed analysis
100 # Show symbols larger than this in detailed analysis
)
# Lower threshold for RAM symbols (RAM is more constrained)
RAM_SYMBOL_SIZE_THRESHOLD: int = 24
-1
View File
@@ -10,7 +10,6 @@
# pylint: disable=unused-import
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
ComponentMarker,
Expression,
FlashStringLiteral,
LineComment,
+3 -1
View File
@@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type == "wav":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
elif file_type in ("mp3", "mpeg", "mpga"):
elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"):
# With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2".
# Treat those labels as MP3 so we still pick the MP3 decoder.
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
elif file_type == "flac":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
+62
View File
@@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_KEY_ID = "key_id"
CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision"
CONF_NVS_ENCRYPTION = "nvs_encryption"
CONF_RELEASE = "release"
CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification"
CONF_SIGNING_KEY = "signing_key"
@@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = {
VARIANT_ESP32,
}
# NVS encryption (HMAC peripheral scheme) is only available on variants that
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
# should be added here.
NVS_ENCRYPTION_HMAC_VARIANTS = {
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32H2,
VARIANT_ESP32P4,
}
COMPILER_OPTIMIZATIONS = {
"DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG",
"NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE",
@@ -1349,6 +1365,29 @@ def final_validate(config):
"Binaries will NOT be signed automatically during build. "
"You must sign them externally before flashing."
)
if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None:
variant = config[CONF_VARIANT]
if variant in NVS_ENCRYPTION_HMAC_VARIANTS:
_LOGGER.warning(
"NVS encryption will burn an HMAC key into eFuse key block %d on the "
"first boot of each device. This is PERMANENT and IRREVERSIBLE: "
"the block cannot be erased or reused afterwards. Enabling (or "
"later disabling) encryption also wipes any previously saved "
"preferences once, because the older data can no longer be read.",
nvs_enc[CONF_KEY_ID],
)
else:
supported = ", ".join(
sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS)
)
errs.append(
cv.Invalid(
f"NVS encryption (HMAC scheme) is not supported on "
f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). "
f"Supported variants: {supported}.",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION],
)
)
if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]:
project = full_config[CONF_ESPHOME].get(CONF_PROJECT)
errs.extend(
@@ -1609,6 +1648,15 @@ FRAMEWORK_SCHEMA = cv.Schema(
),
cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
),
cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema(
{
# eFuse key block (0-5) that stores the HMAC key from
# which the NVS encryption keys are derived. The block is
# written on first boot if empty -- an irreversible
# operation -- so it must be chosen explicitly.
cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5),
}
),
cv.Optional(
CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False
): cv.boolean,
@@ -2451,6 +2499,20 @@ async def to_code(config):
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
# Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are
# derived at runtime from an HMAC key stored in the configured eFuse block
# (no flash encryption required). The HMAC key is generated and burned into
# the eFuse block on first boot if it is empty. With the scheme selected,
# nvs_sec_provider registers it at startup and the default nvs_flash_init()
# (used in esp32/preferences.cpp) transparently performs the secure init, so
# no C++ changes are needed.
if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None:
add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True)
add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True)
add_idf_sdkconfig_option(
"CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID]
)
cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE])
cg.add_define(
+47 -5
View File
@@ -9,6 +9,8 @@
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#include <esp_bt.h>
#else
#include "esphome/components/watchdog/watchdog.h"
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#include <esp_hosted_misc.h>
@@ -33,6 +35,19 @@ namespace esphome::esp32_ble {
static const char *const TAG = "esp32_ble";
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
// Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog
// is also 5 seconds. If the co-processor firmware does not answer (for example
// factory firmware without Bluetooth support), the watchdog would reboot the
// device before the RPC could return an error, causing a boot loop. Raise the
// watchdog for the duration of the bring-up so failures surface as error
// returns instead. 60 seconds covers the worst case: transport reconnect
// (up to ~20s), version preflight (1s), controller init/enable (5s each) and
// the bluedroid host bring-up over the hosted HCI transport.
static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
#endif
// GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_
#define GAP_SCAN_COMPLETE_EVENTS \
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \
@@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() {
bool ESP32BLE::ble_setup_() {
esp_err_t err;
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) {
// start bt controller
@@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() {
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
#else
esp_hosted_connect_to_slave(); // NOLINT
if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT
ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled");
return false;
}
// Fast preflight (1 second RPC timeout): verifies the co-processor answers
// RPCs at all before the 5 second timeout BT controller RPCs below, and
// before hosted_hci_bluedroid_open(), which aborts if the transport is down.
esp_hosted_coprocessor_fwver_t fw_ver{};
if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) {
ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted "
"update component");
return false;
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
if (esp_hosted_bt_controller_init() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed");
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
if (esp_hosted_bt_controller_enable() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed");
ESP_LOGE(TAG,
"BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
@@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() {
}
bool ESP32BLE::ble_dismantle_() {
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
// Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
esp_err_t err = esp_bluedroid_disable();
if (err != ESP_OK) {
// ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine
@@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() {
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed");
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
return false;
}
if (esp_hosted_bt_controller_deinit(false) != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed");
ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed");
return false;
}
@@ -18,6 +18,8 @@ from esphome.const import (
from esphome.cpp_generator import add_define
CODEOWNERS = ["@swoboda1337"]
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
+1 -10
View File
@@ -274,16 +274,7 @@ async def to_code(config):
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[
f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}",
# Override the ancient tool-esptoolpy ~1.30000.0 pinned by
# platform-espressif8266 4.2.1 with the same 5.x build the
# pioarduino ESP32 platform uses, so both platforms share the
# same installed package and stop reinstalling on every switch.
# The 0.0.1 path component is pioarduino's stable registry
# release tag (not the tool version); the tool itself is 5.2.0.
"pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip",
],
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
# Default for platformio is LWIP2_LOW_MEMORY with:
+1 -1
View File
@@ -83,7 +83,7 @@ void FanCall::validate_() {
auto traits = this->parent_.get_traits();
if (this->speed_.has_value()) {
this->speed_ = clamp(*this->speed_, 1, static_cast<int>(traits.supported_speed_count()));
this->speed_ = clamp(*this->speed_, 1, traits.supported_speed_count());
// https://developers.home-assistant.io/docs/core/entity/fan/#preset-modes
// "Manually setting a speed must disable any set preset mode"
+3 -3
View File
@@ -14,7 +14,7 @@ class FanTraits {
public:
FanTraits() = default;
FanTraits(bool oscillation, bool speed, bool direction, uint8_t speed_count)
FanTraits(bool oscillation, bool speed, bool direction, int speed_count)
: oscillation_(oscillation), speed_(speed), direction_(direction), speed_count_(speed_count) {}
/// Return if this fan supports oscillation.
@@ -26,9 +26,9 @@ class FanTraits {
/// Set whether this fan supports speed levels.
void set_speed(bool speed) { this->speed_ = speed; }
/// Return how many speed levels the fan has
uint8_t supported_speed_count() const { return this->speed_count_; }
int supported_speed_count() const { return this->speed_count_; }
/// Set how many speed levels this fan has.
void set_supported_speed_count(uint8_t speed_count) { this->speed_count_ = speed_count; }
void set_supported_speed_count(int speed_count) { this->speed_count_ = speed_count; }
/// Return if this fan supports changing direction
bool supports_direction() const { return this->direction_; }
/// Set whether this fan supports changing direction
+1 -1
View File
@@ -39,7 +39,7 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_DECAY_MODE, default="SLOW"): cv.enum(
DECAY_MODE_OPTIONS, upper=True
),
cv.Optional(CONF_SPEED_COUNT, default=100): cv.int_range(min=1, max=255),
cv.Optional(CONF_SPEED_COUNT, default=100): cv.int_range(min=1),
cv.Optional(CONF_ENABLE_PIN): cv.use_id(output.FloatOutput),
cv.Optional(CONF_PRESET_MODES): validate_preset_modes,
}
+2 -2
View File
@@ -14,7 +14,7 @@ enum DecayMode {
class HBridgeFan final : public Component, public fan::Fan {
public:
HBridgeFan(uint8_t speed_count, DecayMode decay_mode) : speed_count_(speed_count), decay_mode_(decay_mode) {}
HBridgeFan(int speed_count, DecayMode decay_mode) : speed_count_(speed_count), decay_mode_(decay_mode) {}
void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; }
void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; }
@@ -35,7 +35,7 @@ class HBridgeFan final : public Component, public fan::Fan {
output::FloatOutput *pin_b_;
output::FloatOutput *enable_{nullptr};
output::BinaryOutput *oscillating_{nullptr};
uint8_t speed_count_{};
int speed_count_{};
DecayMode decay_mode_{DECAY_MODE_SLOW};
fan::FanTraits traits_;
+1 -1
View File
@@ -187,7 +187,7 @@ void HeatpumpIRClimate::transmit_state() {
swing_h_cmd = HDIR_SWING;
}
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
case climate::CLIMATE_FAN_LOW:
fan_speed_cmd = FAN_2;
break;
+1 -1
View File
@@ -118,7 +118,7 @@ void IDFI2CBus::dump_config() {
if (s.second) {
ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first);
} else {
ESP_LOGCONFIG(TAG, "Unknown error at address 0x%02X", s.first);
ESP_LOGE(TAG, "Unknown error at address 0x%02X", s.first);
}
}
}
@@ -1,7 +1,6 @@
#pragma once
#include "esphome/components/improv_base/improv_base.h"
#include "esphome/components/logger/logger.h"
#include "esphome/components/wifi/wifi_component.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
@@ -66,53 +65,7 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
std::vector<uint8_t> build_rpc_settings_response_(improv::Command command);
std::vector<uint8_t> build_version_info_();
ESPHOME_ALWAYS_INLINE optional<uint8_t> read_byte_() {
optional<uint8_t> byte;
uint8_t data = 0;
#ifdef USE_ESP32
switch (this->uart_selection_) {
case logger::UART_SELECTION_UART0:
case logger::UART_SELECTION_UART1:
#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
!defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3)
case logger::UART_SELECTION_UART2:
#endif
if (this->uart_num_ >= 0) {
size_t available;
uart_get_buffered_data_len(this->uart_num_, &available);
if (available) {
uart_read_bytes(this->uart_num_, &data, 1, 0);
byte = data;
}
}
break;
#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
case logger::UART_SELECTION_USB_CDC:
if (esp_usb_console_available_for_read()) {
esp_usb_console_read_buf((char *) &data, 1);
byte = data;
}
break;
#endif
#ifdef USE_LOGGER_USB_SERIAL_JTAG
case logger::UART_SELECTION_USB_SERIAL_JTAG: {
if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) {
byte = data;
}
break;
}
#endif
default:
break;
}
#elif defined(USE_ARDUINO)
if (this->hw_serial_->available()) {
this->hw_serial_->readBytes(&data, 1);
byte = data;
}
#endif
return byte;
}
optional<uint8_t> read_byte_();
void write_data_(const uint8_t *data = nullptr, size_t size = 0);
uint8_t tx_header_[TX_BUFFER_SIZE] = {
@@ -132,7 +85,6 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
#ifdef USE_ESP32
uart_port_t uart_num_;
logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0};
#elif defined(USE_ARDUINO)
Stream *hw_serial_{nullptr};
#endif
+1 -1
View File
@@ -21,6 +21,7 @@ from esphome.const import (
UNIT_EMPTY,
UNIT_KELVIN,
UNIT_KILOWATT,
UNIT_LITRE_PER_HOUR,
)
CODEOWNERS = ["@cfeenstra1024"]
@@ -37,7 +38,6 @@ CONF_TEMP2 = "temp2"
CONF_TEMP_DIFF = "temp_diff"
UNIT_GIGA_JOULE = "GJ"
UNIT_LITRE_PER_HOUR = "l/h"
# Note: The sensor units are set automatically based un the received data from the meter
CONFIG_SCHEMA = (
+1 -1
View File
@@ -10,7 +10,7 @@ static const char *const TAG = "kuntze";
static const uint8_t CMD_READ_REG = 0x03;
static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832};
// Maximum bytes to log for Modbus responses (2 registers = 4 bytes, plus byte count = 5 bytes)
// Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5)
static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8;
void Kuntze::on_modbus_data(const std::vector<uint8_t> &data) {
-3
View File
@@ -31,9 +31,6 @@
namespace esphome::ld24xx {
/// Parse a little-endian 16-bit unsigned value from two bytes.
static constexpr uint16_t two_byte_to_uint16(uint8_t low, uint8_t high) { return ((uint16_t) high << 8) | low; }
// Helper to find index of value in constexpr array
template<size_t N> optional<size_t> find_index(const uint32_t (&arr)[N], uint32_t value) {
for (size_t i = 0; i < N; i++) {
+10 -8
View File
@@ -213,17 +213,19 @@ LightColorValues LightCall::validate_() {
// Flag whether an explicit turn off was requested, in which case we'll also stop the effect.
bool explicit_turn_off_request = this->has_state() && !this->state_;
// Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on).
if (this->has_brightness() && this->brightness_ == 0.0f) {
// Treat zero brightness as an implicit turn-off when no state was explicitly requested.
if (this->has_brightness() && this->brightness_ == 0.0f && !this->has_state()) {
this->state_ = false;
this->set_flag_(FLAG_HAS_STATE);
if (color_mode & ColorCapability::BRIGHTNESS) {
// Reset brightness so the light has nonzero brightness when turned back on.
}
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
if (brightness == 0.0f) {
this->brightness_ = 1.0f;
} else {
// Light doesn't support brightness; clear the flag to avoid a spurious
// "brightness not supported" warning during capability validation.
this->clear_flag_(FLAG_HAS_BRIGHTNESS);
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
}
+6
View File
@@ -414,6 +414,10 @@ async def to_code(configs):
await cg.register_component(lv_component, config)
if rotation := config.get(CONF_ROTATION):
cg.add(lv_component.set_rotation(rotation))
if paused := config[df.CONF_PAUSED]:
cg.add(lv_component.set_paused(paused, False))
if refr_time := config.get(df.CONF_REFRESH_INTERVAL):
cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds))
Widget.create(config[CONF_ID], lv_component, LvScrActType(), config)
lv_scr_act = get_screen_active(lv_component)
@@ -598,6 +602,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
cv.Optional(df.CONF_REFRESH_INTERVAL): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_ROTATION): validate_rotation,
@@ -642,6 +647,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
cv.Optional(df.CONF_PAUSED, default=False): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA)
+2
View File
@@ -758,12 +758,14 @@ CONF_PAD_COLUMN = "pad_column"
CONF_PAGE = "page"
CONF_PAGE_WRAP = "page_wrap"
CONF_PASSWORD_MODE = "password_mode"
CONF_PAUSED = "paused"
CONF_PIVOT_X = "pivot_x"
CONF_PIVOT_Y = "pivot_y"
CONF_PLACEHOLDER_TEXT = "placeholder_text"
CONF_POINTS = "points"
CONF_PREVIOUS = "previous"
CONF_RADIUS = "radius"
CONF_REFRESH_INTERVAL = "refresh_interval"
CONF_REPEAT_COUNT = "repeat_count"
CONF_RECOLOR = "recolor"
CONF_RESUME_ON_INPUT = "resume_on_input"
+38 -22
View File
@@ -401,7 +401,10 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) {
}
void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) {
if (!this->is_paused()) {
// no guard here for display busy, since LVGL will not call flush_cb until the refresh timer fires,
// and while the display is busy this is reset to 5 minutes. If that expires and the display is still
// busy there are bigger problems.
if (!this->paused_) {
auto now = millis();
this->draw_buffer_(area, reinterpret_cast<lv_color_data *>(color_p));
ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", (int) area->x1, (int) area->y1,
@@ -620,20 +623,20 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) {
void LvglComponent::draw_end_() {
if (this->draw_end_callback_ != nullptr)
this->draw_end_callback_->trigger();
// Only reachable once the display is idle again: while busy, the display's refr_timer_ is
// paused (see loop()), so LVGL never renders/flushes and this event never fires.
if (this->update_when_display_idle_) {
for (auto *disp : this->displays_)
disp->update();
}
}
bool LvglComponent::is_paused() const {
if (this->paused_)
return true;
if (this->update_when_display_idle_) {
for (auto *disp : this->displays_) {
if (!disp->is_idle())
return true;
}
bool LvglComponent::displays_busy_() const {
if (!this->update_when_display_idle_)
return false;
for (auto *disp : this->displays_) {
if (!disp->is_idle())
return true;
}
return false;
}
@@ -777,6 +780,8 @@ void LvglComponent::setup() {
if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) {
lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this);
}
this->refr_timer_ = lv_display_get_refr_timer(this->disp_);
lv_timer_set_period(this->refr_timer_, this->refr_timer_period_);
#if LV_USE_LOG
lv_log_register_print_cb([](lv_log_level_t level, const char *buf) {
auto next = strchr(buf, ')');
@@ -802,21 +807,32 @@ void LvglComponent::update() {
}
void LvglComponent::loop() {
if (this->is_paused()) {
if (this->paused_ && this->show_snow_)
if (this->paused_) {
if (this->show_snow_)
this->write_random_();
} else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
auto now = millis();
lv_timer_handler();
auto elapsed = millis() - now;
if (elapsed > 15) {
ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now));
}
#else
lv_timer_handler();
#endif
return;
}
// Pause/resume the display's own refresh timer to track its busy state. While paused, LVGL
// still keeps track of invalidated areas but won't render or flush them, so nothing needs to
// be discarded or replayed: once resumed, the accumulated areas are simply drawn as normal.
// Input events and other timers keep being processed below regardless of this state.
if (this->update_when_display_idle_) {
bool busy = this->displays_busy_();
if (busy && !this->refr_timer_paused_) {
this->refr_timer_paused_ = true;
// calling lv_timer_pause() here would be ineffective; LVGL pauses and resumes the timer based on its own internal
// state, which is not aware of the display's busy state. Instead, we extend the timer period to avoid it firing
// while the display is busy.
lv_timer_set_period(this->refr_timer_, 5 * 60 * 1000);
} else if (!busy && this->refr_timer_paused_) {
this->refr_timer_paused_ = false;
lv_timer_set_period(this->refr_timer_, this->refr_timer_period_);
// Don't wait for the timer's next natural period: refresh right away now that the
// display is idle again.
lv_timer_ready(this->refr_timer_);
}
}
lv_timer_handler();
}
#ifdef USE_LVGL_ANIMIMG
+18 -2
View File
@@ -214,9 +214,14 @@ class LvglComponent final : public PollingComponent {
// @param paused If true, pause the display. If false, resume the display.
// @param show_snow If true, show the snow effect when paused.
void set_paused(bool paused, bool show_snow);
void set_refresh_interval(uint32_t period) {
this->refr_timer_period_ = period;
if (this->refr_timer_ != nullptr)
lv_timer_set_period(this->refr_timer_, period);
}
// Returns true if the display is explicitly paused, or a blocking display update is in progress.
bool is_paused() const;
// Returns true if the display has been explicitly paused via set_paused().
bool is_paused() const { return this->paused_; }
// If the display is paused and we have resume_on_input_ set to true, resume the display.
void maybe_wakeup() {
if (this->paused_ && this->resume_on_input_) {
@@ -299,6 +304,9 @@ class LvglComponent final : public PollingComponent {
// Not checking for non-null callback since the
// LVGL callback that calls it is not set in that case
void draw_start_() const { this->draw_start_callback_->trigger(); }
// Returns true if update_when_display_idle is enabled and at least one underlying display
// component is currently busy (e.g. mid-refresh).
bool displays_busy_() const;
void write_random_();
void draw_buffer_(const lv_area_t *area, lv_color_data *ptr);
@@ -316,6 +324,14 @@ class LvglComponent final : public PollingComponent {
uint8_t *draw_buf_{};
lv_display_t *disp_{};
// The display's own periodic refresh timer, effectively paused while the display is busy (see
// displays_busy_()) so LVGL neither renders nor flushes to it, without losing track of
// invalidated areas. Other timers (indev reading, animations, ...) keep running as normal.
lv_timer_t *refr_timer_{};
// Tracks whether refr_timer_ is currently paused, so loop() can detect the busy -> idle edge
// and kick off an immediate refresh instead of waiting for the timer's next natural period.
bool refr_timer_paused_{};
uint32_t refr_timer_period_{16};
uint16_t width_{};
uint16_t height_{};
bool paused_{};
+1 -1
View File
@@ -155,7 +155,7 @@ void MideaIR::transmit_state() {
data.set_fahrenheit(this->fahrenheit_);
data.set_temp(this->target_temperature);
data.set_mode(this->mode);
data.set_fan_mode(this->fan_mode.value_or(ClimateFanMode::CLIMATE_FAN_ON));
data.set_fan_mode(this->fan_mode.value_or(ClimateFanMode::CLIMATE_FAN_AUTO));
data.set_sleep_preset(this->preset == climate::CLIMATE_PRESET_SLEEP);
data.fix();
this->transmit_(data);
+49 -2
View File
@@ -5,6 +5,7 @@ from esphome.components.audio_dac import AudioDac
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_ENABLE_PIN,
CONF_ID,
CONF_INPUT,
CONF_INVERTED,
@@ -16,6 +17,11 @@ from esphome.const import (
CODEOWNERS = ["@remcom"]
DEPENDENCIES = ["i2c"]
CONF_ANALOG_GAIN = "analog_gain"
CONF_CHANNEL_MIX = "channel_mix"
CONF_VOLUME_MIN_DB = "volume_min_db"
CONF_VOLUME_MAX_DB = "volume_max_db"
pcm5122_ns = cg.esphome_ns.namespace("pcm5122")
PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice)
CONF_PCM5122 = "pcm5122"
@@ -27,26 +33,60 @@ PCM5122_BITS_PER_SAMPLE_ENUM = {
32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32,
}
pcm5122_analog_gain = pcm5122_ns.enum("PCM5122AnalogGain")
PCM5122_ANALOG_GAIN_ENUM = {
"0db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_0DB,
"-6db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_MINUS_6DB,
}
pcm5122_channel_mix = pcm5122_ns.enum("PCM5122ChannelMix")
PCM5122_CHANNEL_MIX_ENUM = {
"stereo": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_STEREO,
"left": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_LEFT_ONLY,
"right": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_RIGHT_ONLY,
"swapped": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_SWAPPED,
}
_validate_bits = cv.float_with_unit("bits", "bit")
def _validate_volume_range(config):
if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]:
raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}")
return config
PCM5122GPIOPin = pcm5122_ns.class_(
"PCM5122GPIOPin",
cg.GPIOPin,
cg.Parented.template(PCM5122),
)
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(PCM5122),
cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All(
_validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM)
),
cv.Optional(CONF_ANALOG_GAIN, default="0db"): cv.enum(
PCM5122_ANALOG_GAIN_ENUM, lower=True
),
cv.Optional(CONF_CHANNEL_MIX, default="stereo"): cv.enum(
PCM5122_CHANNEL_MIX_ENUM, lower=True
),
cv.Optional(CONF_VOLUME_MIN_DB, default="-52.5dB"): cv.All(
cv.decibel, cv.float_range(min=-103.0, max=24.0)
),
cv.Optional(CONF_VOLUME_MAX_DB, default="0dB"): cv.All(
cv.decibel, cv.float_range(min=-103.0, max=24.0)
),
cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x4D))
.extend(i2c.i2c_device_schema(0x4D)),
_validate_volume_range,
)
@@ -96,3 +136,10 @@ async def to_code(config):
await i2c.register_i2c_device(var, config)
cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN]))
cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX]))
cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB]))
cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB]))
if enable_pin_config := config.get(CONF_ENABLE_PIN):
enable_pin = await cg.gpio_pin_expression(enable_pin_config)
cg.add(var.set_enable_pin(enable_pin))
+99 -5
View File
@@ -10,6 +10,12 @@ namespace esphome::pcm5122 {
static const char *const TAG = "pcm5122";
void PCM5122::setup() {
// Hold XSMT low (soft mute asserted) until init completes
if (this->enable_pin_ != nullptr) {
this->enable_pin_->setup();
this->enable_pin_->digital_write(false);
}
// Select page 0 and verify chip presence via I2C ACK
if (!this->select_page_(0)) {
ESP_LOGE(TAG, "Write failed");
@@ -51,7 +57,22 @@ void PCM5122::setup() {
}
this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen;
if (!this->write_channel_mix_()) {
this->mark_failed();
return;
}
if (!this->write_analog_gain_()) {
this->mark_failed();
return;
}
// PLL reference clock: BCK
if (!this->select_page_(0)) {
ESP_LOGE(TAG, "Write failed");
this->mark_failed();
return;
}
optional<uint8_t> pll_ref = this->read_byte(PCM5122_REG_PLL_REF);
if (!pll_ref.has_value()) {
ESP_LOGE(TAG, "Failed to read PLL_REF");
@@ -67,15 +88,40 @@ void PCM5122::setup() {
this->mark_failed();
return;
}
// Release XSMT (soft un-mute) now that init has completed
if (this->enable_pin_ != nullptr) {
this->enable_pin_->digital_write(true);
}
}
void PCM5122::dump_config() {
const char *channel_mix_str;
switch (this->channel_mix_) {
case PCM5122_CHANNEL_MIX_LEFT_ONLY:
channel_mix_str = "left only";
break;
case PCM5122_CHANNEL_MIX_RIGHT_ONLY:
channel_mix_str = "right only";
break;
case PCM5122_CHANNEL_MIX_SWAPPED:
channel_mix_str = "swapped";
break;
default:
channel_mix_str = "stereo";
break;
}
ESP_LOGCONFIG(TAG, "Audio DAC:");
LOG_I2C_DEVICE(this);
ESP_LOGCONFIG(TAG,
" Bits per sample: %u\n"
" Analog gain: %s\n"
" Channel mix: %s\n"
" Volume range: %.1f dB to %.1f dB\n"
" Muted: %s",
this->bits_per_sample_, YESNO(this->is_muted_));
this->bits_per_sample_, this->analog_gain_ == PCM5122_ANALOG_GAIN_0DB ? "0 dB" : "-6 dB",
channel_mix_str, this->volume_min_db_, this->volume_max_db_, YESNO(this->is_muted_));
LOG_PIN(" Enable Pin: ", this->enable_pin_);
}
bool PCM5122::set_mute_off() {
@@ -118,11 +164,11 @@ bool PCM5122::write_mute_() {
}
bool PCM5122::write_volume_() {
// DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step).
// Note: volume=0.0 maps to -52.5 dB (still audible), not true silence.
// DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFE = -103 dB, 0xFF = mute (-0.5 dB/step).
// Note: volume=0.0 maps to volume_min_db_, which is not true silence unless set to -103 dB.
// Use set_mute_on() for silence.
const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale
const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum
const uint8_t dvol_max_volume = static_cast<uint8_t>(lroundf(0x30 - this->volume_max_db_ * 2.0f));
const uint8_t dvol_min_volume = static_cast<uint8_t>(lroundf(0x30 - this->volume_min_db_ * 2.0f));
const uint8_t volume_byte =
dvol_max_volume + static_cast<uint8_t>(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume)));
@@ -137,4 +183,52 @@ bool PCM5122::write_volume_() {
return true;
}
bool PCM5122::write_analog_gain_() {
uint8_t gain_byte = this->analog_gain_;
if (!this->select_page_(1) || !this->write_byte(PCM5122_REG_ANALOG_GAIN, gain_byte)) {
ESP_LOGE(TAG, "Writing analog gain failed");
return false;
}
return true;
}
bool PCM5122::write_channel_mix_() {
uint8_t channel_mix_byte = this->channel_mix_;
if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DAC_DATA_PATH, channel_mix_byte)) {
ESP_LOGE(TAG, "Writing channel mix failed");
return false;
}
return true;
}
bool PCM5122::set_standby(bool enable) {
bool prev_standby = this->standby_;
this->standby_ = enable;
if (!this->write_power_control_()) {
this->standby_ = prev_standby;
return false;
}
return true;
}
bool PCM5122::set_powerdown(bool enable) {
bool prev_powerdown = this->powerdown_;
this->powerdown_ = enable;
if (!this->write_power_control_()) {
this->powerdown_ = prev_powerdown;
return false;
}
return true;
}
bool PCM5122::write_power_control_() {
uint8_t power_byte =
(this->standby_ ? PCM5122_POWER_CONTROL_RQST : 0) | (this->powerdown_ ? PCM5122_POWER_CONTROL_RQPD : 0);
if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_POWER_CONTROL, power_byte)) {
ESP_LOGE(TAG, "Writing power control failed");
return false;
}
return true;
}
} // namespace esphome::pcm5122
+47 -2
View File
@@ -3,6 +3,7 @@
#include "esphome/components/audio_dac/audio_dac.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
namespace esphome::pcm5122 {
@@ -10,11 +11,13 @@ namespace esphome::pcm5122 {
// Page 0 register addresses
static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00;
static const uint8_t PCM5122_REG_RESET = 0x01;
static const uint8_t PCM5122_REG_POWER_CONTROL = 0x02;
static const uint8_t PCM5122_REG_MUTE = 0x03;
static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08;
static const uint8_t PCM5122_REG_PLL_REF = 0x0D;
static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25;
static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28;
static const uint8_t PCM5122_REG_DAC_DATA_PATH = 0x2A;
static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D;
static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E;
static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1
@@ -23,6 +26,9 @@ static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56;
static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57;
static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77;
// Page 1 register addresses
static const uint8_t PCM5122_REG_ANALOG_GAIN = 0x02;
// Register values for init sequence
static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules
static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00)
@@ -35,12 +41,33 @@ static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1);
static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4]
static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK)
// Page 0, Register 2 (Power Control): RQST = standby request, RQPD = powerdown request (§10.5.3)
static const uint8_t PCM5122_POWER_CONTROL_RQST = (1 << 4);
static const uint8_t PCM5122_POWER_CONTROL_RQPD = (1 << 0);
// Page 1, Register 2 (Analog Gain Control): LAGN/RAGN select 0 dB or -6 dB analog gain (§8.3.5.5)
static const uint8_t PCM5122_ANALOG_GAIN_LAGN = (1 << 4);
static const uint8_t PCM5122_ANALOG_GAIN_RAGN = (1 << 0);
enum PCM5122BitsPerSample : uint8_t {
PCM5122_BITS_PER_SAMPLE_16 = 16,
PCM5122_BITS_PER_SAMPLE_24 = 24,
PCM5122_BITS_PER_SAMPLE_32 = 32,
};
enum PCM5122AnalogGain : uint8_t {
PCM5122_ANALOG_GAIN_0DB = 0x00,
PCM5122_ANALOG_GAIN_MINUS_6DB = PCM5122_ANALOG_GAIN_LAGN | PCM5122_ANALOG_GAIN_RAGN,
};
// Page 0, Register 0x2A (DAC Data Path): AUPL/AUPR select which channel's data feeds each output (§7.4.2.42)
enum PCM5122ChannelMix : uint8_t {
PCM5122_CHANNEL_MIX_STEREO = 0x11, // Left data -> left out, right data -> right out
PCM5122_CHANNEL_MIX_LEFT_ONLY = 0x12, // Left data -> both outputs
PCM5122_CHANNEL_MIX_RIGHT_ONLY = 0x21, // Right data -> both outputs
PCM5122_CHANNEL_MIX_SWAPPED = 0x22, // Left/right outputs swapped
};
class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
public:
void setup() override;
@@ -48,6 +75,11 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::
float get_setup_priority() const override { return setup_priority::IO; }
void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; }
void set_analog_gain(PCM5122AnalogGain analog_gain) { this->analog_gain_ = analog_gain; }
void set_channel_mix(PCM5122ChannelMix channel_mix) { this->channel_mix_ = channel_mix; }
void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; }
void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; }
void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; }
bool set_mute_off() override;
bool set_mute_on() override;
@@ -56,17 +88,30 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::
bool is_muted() override;
float volume() override;
bool set_standby(bool enable);
bool set_powerdown(bool enable);
friend class PCM5122GPIOPin;
protected:
bool select_page_(uint8_t page);
bool write_mute_();
bool write_volume_();
bool write_analog_gain_();
bool write_channel_mix_();
bool write_power_control_();
float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB)
int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes
GPIOPin *enable_pin_{nullptr};
float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB)
float volume_min_db_{-52.5f}; // Matches the previous hardcoded minimum (0x99)
float volume_max_db_{0.0f}; // Matches the previous hardcoded maximum (0x30)
int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes
bool is_muted_{false};
bool standby_{false};
bool powerdown_{false};
PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16};
PCM5122AnalogGain analog_gain_{PCM5122_ANALOG_GAIN_0DB};
PCM5122ChannelMix channel_mix_{PCM5122_CHANNEL_MIX_STEREO};
};
} // namespace esphome::pcm5122
@@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG
from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns
PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch)
pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode")
PCM5122_POWER_SWITCH_MODE_ENUM = {
"standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY,
"powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN,
}
CONFIG_SCHEMA = switch.switch_schema(
PCM5122PowerSwitch,
entity_category=ENTITY_CATEGORY_CONFIG,
).extend(
{
cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122),
cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum(
PCM5122_POWER_SWITCH_MODE_ENUM, lower=True
),
}
)
async def to_code(config):
var = await switch.new_switch(config)
await cg.register_parented(var, config[CONF_PCM5122])
cg.add(var.set_power_mode(config[CONF_POWER_MODE]))
@@ -0,0 +1,12 @@
#include "power_switch.h"
namespace esphome::pcm5122 {
void PCM5122PowerSwitch::write_state(bool state) {
bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state)
: this->parent_->set_powerdown(state);
if (ok)
this->publish_state(state);
}
} // namespace esphome::pcm5122
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/components/switch/switch.h"
#include "../pcm5122.h"
namespace esphome::pcm5122 {
enum PCM5122PowerSwitchMode : uint8_t {
PCM5122_POWER_SWITCH_MODE_STANDBY,
PCM5122_POWER_SWITCH_MODE_POWERDOWN,
};
class PCM5122PowerSwitch final : public switch_::Switch, public Parented<PCM5122> {
public:
void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; }
protected:
void write_state(bool state) override;
PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN};
};
} // namespace esphome::pcm5122
+1
View File
@@ -10,6 +10,7 @@ import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
CONF_ENABLE_FULL_PRINTF,
CONF_FRAMEWORK,
CONF_PLATFORM_VERSION,
CONF_SOURCE,
-1
View File
@@ -1,6 +1,5 @@
import esphome.codegen as cg
CONF_ENABLE_FULL_PRINTF = "enable_full_printf"
KEY_BOARD = "board"
KEY_LWIP_OPTS = "lwip_opts"
KEY_RP2 = "rp2"
@@ -81,19 +81,6 @@ void RuntimeStatsCollector::log_stats_() {
ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms",
static_cast<double>(before) / 1000.0, static_cast<double>(tail) / 1000.0,
static_cast<double>(inter) / 1000.0);
uint64_t sched = this->period_before_sched_time_us_;
uint64_t wdt = this->period_before_wdt_time_us_;
uint64_t before_residual = before > sched + wdt ? before - sched - wdt : 0;
ESP_LOGI(TAG, " main_loop_before_breakdown: sched=%.1fms, wdt=%.1fms, residual=%.1fms",
static_cast<double>(sched) / 1000.0, static_cast<double>(wdt) / 1000.0,
static_cast<double>(before_residual) / 1000.0);
uint32_t wdt_slow_hits = App.wdt_slow_count_;
App.wdt_slow_count_ = 0;
ESP_LOGI(TAG, " wdt_slow_path: hits=%" PRIu32 " (%.1f%% of iters, avg=%.2fus/hit)", wdt_slow_hits,
this->period_active_count_ > 0
? 100.0 * static_cast<double>(wdt_slow_hits) / static_cast<double>(this->period_active_count_)
: 0.0,
wdt_slow_hits > 0 ? static_cast<double>(wdt) / static_cast<double>(wdt_slow_hits) : 0.0);
}
// Log total stats since boot (only for active components - idle ones haven't changed)
@@ -129,12 +116,6 @@ void RuntimeStatsCollector::log_stats_() {
ESP_LOGI(TAG, " main_loop_overhead_section: before=%.1fms, tail=%.1fms, inter_component=%.1fms",
static_cast<double>(before) / 1000.0, static_cast<double>(tail) / 1000.0,
static_cast<double>(inter) / 1000.0);
uint64_t sched = this->total_before_sched_time_us_;
uint64_t wdt = this->total_before_wdt_time_us_;
uint64_t before_residual = before > sched + wdt ? before - sched - wdt : 0;
ESP_LOGI(TAG, " main_loop_before_breakdown: sched=%.1fms, wdt=%.1fms, residual=%.1fms",
static_cast<double>(sched) / 1000.0, static_cast<double>(wdt) / 1000.0,
static_cast<double>(before_residual) / 1000.0);
}
// Reset period stats
@@ -145,8 +126,6 @@ void RuntimeStatsCollector::log_stats_() {
this->period_active_time_us_ = 0;
this->period_active_max_us_ = 0;
this->period_before_time_us_ = 0;
this->period_before_sched_time_us_ = 0;
this->period_before_wdt_time_us_ = 0;
this->period_tail_time_us_ = 0;
}
@@ -49,8 +49,7 @@ class RuntimeStatsCollector final {
// which captures per-iteration inter-component bookkeeping (set_current_component,
// LoopBlockingGuard construction/destruction, feed_wdt_with_time calls,
// the for-loop itself).
void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t sched_us, uint32_t wdt_us,
uint32_t tail_us) {
void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) {
this->period_active_count_++;
this->period_active_time_us_ += active_us;
if (active_us > this->period_active_max_us_)
@@ -62,10 +61,6 @@ class RuntimeStatsCollector final {
this->period_before_time_us_ += before_us;
this->total_before_time_us_ += before_us;
this->period_before_sched_time_us_ += sched_us;
this->total_before_sched_time_us_ += sched_us;
this->period_before_wdt_time_us_ += wdt_us;
this->total_before_wdt_time_us_ += wdt_us;
this->period_tail_time_us_ += tail_us;
this->total_tail_time_us_ += tail_us;
}
@@ -93,11 +88,6 @@ class RuntimeStatsCollector final {
// Split of overhead sections — accumulated per iteration.
uint64_t period_before_time_us_{0};
uint64_t total_before_time_us_{0};
// Sub-split of `before` into scheduler_tick_ vs. post-scheduler feed_wdt_with_time.
uint64_t period_before_sched_time_us_{0};
uint64_t total_before_sched_time_us_{0};
uint64_t period_before_wdt_time_us_{0};
uint64_t total_before_wdt_time_us_{0};
uint64_t period_tail_time_us_{0};
uint64_t total_tail_time_us_{0};
};
+1 -1
View File
@@ -25,7 +25,7 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_SPEED): cv.invalid(
"Configuring individual speeds is deprecated."
),
cv.Optional(CONF_SPEED_COUNT, default=100): cv.int_range(min=1, max=255),
cv.Optional(CONF_SPEED_COUNT, default=100): cv.int_range(min=1),
cv.Optional(CONF_PRESET_MODES): validate_preset_modes,
}
)
+2 -2
View File
@@ -9,7 +9,7 @@ namespace esphome::speed {
class SpeedFan final : public Component, public fan::Fan {
public:
SpeedFan(uint8_t speed_count) : speed_count_(speed_count) {}
SpeedFan(int speed_count) : speed_count_(speed_count) {}
void setup() override;
void dump_config() override;
void set_output(output::FloatOutput *output) { this->output_ = output; }
@@ -28,7 +28,7 @@ class SpeedFan final : public Component, public fan::Fan {
output::FloatOutput *output_;
output::BinaryOutput *oscillating_{nullptr};
output::BinaryOutput *direction_{nullptr};
uint8_t speed_count_{};
int speed_count_{};
fan::FanTraits traits_;
};
+1 -1
View File
@@ -19,7 +19,7 @@ CONFIG_SCHEMA = (
{
cv.Optional(CONF_HAS_DIRECTION, default=False): cv.boolean,
cv.Optional(CONF_HAS_OSCILLATING, default=False): cv.boolean,
cv.Optional(CONF_SPEED_COUNT): cv.int_range(min=1, max=255),
cv.Optional(CONF_SPEED_COUNT): cv.int_range(min=1),
cv.Optional(CONF_PRESET_MODES): validate_preset_modes,
}
)
@@ -24,7 +24,7 @@ class TemplateFan final : public Component, public fan::Fan {
bool has_oscillating_{false};
bool has_direction_{false};
uint8_t speed_count_{0};
int speed_count_{0};
fan::FanTraits traits_;
};
+1 -1
View File
@@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_SPEED_DATAPOINT): cv.uint8_t,
cv.Optional(CONF_SWITCH_DATAPOINT): cv.uint8_t,
cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t,
cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=255),
cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256),
}
)
.extend(cv.COMPONENT_SCHEMA),
+2 -2
View File
@@ -8,7 +8,7 @@ namespace esphome::tuya {
class TuyaFan final : public Component, public fan::Fan {
public:
TuyaFan(Tuya *parent, uint8_t speed_count) : parent_(parent), speed_count_(speed_count) {}
TuyaFan(Tuya *parent, int speed_count) : parent_(parent), speed_count_(speed_count) {}
void setup() override;
void dump_config() override;
void set_speed_id(uint8_t speed_id) { this->speed_id_ = speed_id; }
@@ -26,7 +26,7 @@ class TuyaFan final : public Component, public fan::Fan {
optional<uint8_t> switch_id_{};
optional<uint8_t> oscillation_id_{};
optional<uint8_t> direction_id_{};
uint8_t speed_count_{};
int speed_count_{};
TuyaDatapointType speed_type_{};
TuyaDatapointType oscillation_type_{};
};
+38 -14
View File
@@ -3,6 +3,7 @@
#include "usb_uart.h"
#include "usb/usb_host.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/components/uart/uart_debugger.h"
#include "esphome/components/bytebuffer/bytebuffer.h"
@@ -396,7 +397,14 @@ int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) {
}
void USBUartTypeFT23XX::start_input(USBUartChannel *channel) {
if (!channel->initialised_.load() || channel->input_started_.load())
if (!channel->initialised_.load())
return;
// Use compare_exchange_strong to avoid a check-then-act race: start_input() is called
// from both the USB task (self-restart on success) and the main loop (backpressure
// restart), so a plain load()/store() pair can let both threads submit a transfer.
auto started = false;
if (!channel->input_started_.compare_exchange_strong(started, true))
return;
const auto *ep = channel->cdc_dev_.in_ep;
@@ -408,39 +416,55 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) {
return;
}
// FTDI prepends a 2-byte modem/line status header to every bulk IN packet.
size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0;
if (uart_data_len > 0) {
ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_);
if (!channel->dummy_receiver_) {
// Copy the entire received UART payload into the ring buffer in one
// operation to avoid per-byte overhead and reduce the chance of
// heap activity in hot paths.
channel->input_buffer_.push(status.data + 2, uart_data_len);
UsbDataChunk *chunk = this->chunk_pool_.allocate();
if (chunk == nullptr) {
this->usb_data_queue_.increment_dropped_count();
channel->input_started_.store(false);
// Queue is full — wake the main loop to drain it, then let read_array()
// retrigger start_input() rather than spinning here in the USB task.
this->enable_loop_soon_any_context();
App.wake_loop_threadsafe();
return;
}
// Strip the 2-byte FTDI header before queuing.
memcpy(chunk->data, status.data + 2, uart_data_len);
chunk->length = static_cast<uint16_t>(uart_data_len);
chunk->channel = channel;
this->usb_data_queue_.push(chunk);
#ifdef USE_UART_DEBUGGER
if (channel->debug_) {
// Debug path creates a temporary vector for logging only; this is
// acceptable because debug mode is opt-in and not used in release.
uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX,
std::vector<uint8_t>(status.data + 2, status.data + 2 + uart_data_len), ',',
channel->debug_prefix_);
}
#endif
this->enable_loop_soon_any_context();
App.wake_loop_threadsafe();
}
} else {
} else if (status.data_len >= 2) {
ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1],
channel->index_);
}
channel->input_started_.store(false);
if (channel->dummy_receiver_ ||
channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) {
this->start_input(channel);
}
this->start_input(channel);
};
channel->input_started_.store(true);
this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize);
if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) {
ESP_LOGE(TAG, "RX transfer submission failed for ep=0x%02X", ep->bEndpointAddress);
channel->input_started_.store(false);
}
}
void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) {
ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_);
channel->input_buffer_.clear();
}
void USBUartTypeFT23XX::enable_channels() {
+5
View File
@@ -228,6 +228,11 @@ void USBUartComponent::loop() {
}
#endif
// If there is not enough space for the full chunk, let the device subclass
// handle it (e.g. FTDI clears the buffer to prevent mid-telegram corruption).
if (channel->input_buffer_.get_free_space() < chunk->length) {
this->on_rx_overflow(channel);
}
// Push data to ring buffer (now safe in main loop)
channel->input_buffer_.push(chunk->data, chunk->length);
+7 -2
View File
@@ -192,9 +192,13 @@ class USBUartComponent : public usb_host::USBClient {
void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); }
void start_input(USBUartChannel *channel);
virtual void start_input(USBUartChannel *channel);
void start_output(USBUartChannel *channel);
// Called from loop() when input_buffer_ has insufficient space for the incoming chunk.
// Default is a no-op; override in device-specific subclasses that need resync on overflow.
virtual void on_rx_overflow(USBUartChannel *channel) {}
// Lock-free data transfer from USB task to main loop
static constexpr int USB_DATA_QUEUE_SIZE = 32;
LockFreeQueue<UsbDataChunk, USB_DATA_QUEUE_SIZE> usb_data_queue_;
@@ -248,7 +252,8 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm {
public:
USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {}
void start_input(USBUartChannel *channel);
void start_input(USBUartChannel *channel) override;
void on_rx_overflow(USBUartChannel *channel) override;
protected:
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
@@ -39,7 +39,6 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
+1
View File
@@ -1255,6 +1255,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh"
UNIT_KILOWATT = "kW"
UNIT_KILOWATT_HOURS = "kWh"
UNIT_LITRE = "L"
UNIT_LITRE_PER_HOUR = "L/h"
UNIT_LITRE_PER_SECOND = "L/s"
UNIT_LUX = "lx"
UNIT_MEGAJOULE = "MJ"
+6 -57
View File
@@ -536,42 +536,6 @@ class Library:
return self
def _wrap_in_iifes(lines: list[str], max_statements: int) -> list[str]:
"""Wrap ``lines`` in ``[]() {...}();`` IIFEs of up to ``max_statements``
each. Never splits inside a brace-balanced block (e.g. the ``{`` / ``}``
pair from ``cg.with_local_variable()``), so an IIFE may exceed the cap
when a block straddles it. Comment-only chunks pass through verbatim.
No ``noinline`` attribute GCC's inliner re-folds small chunks freely,
keeping flash small without regressing peak stack."""
out: list[str] = []
chunk: list[str] = []
depth = 0
def flush() -> None:
if not chunk:
return
if all(line.lstrip().startswith("//") for line in chunk):
out.extend(chunk)
else:
out.append("[]() {")
out.extend(chunk)
out.append("}();")
chunk.clear()
for line in lines:
chunk.append(line)
# Count { and } per line so inline control flow (e.g. `if (cond) {`)
# and balanced inline lambdas are tracked correctly. If depth ever
# goes negative (unbalanced input) we never return to 0 and the
# rest falls through into a single final IIFE — safe fallback.
depth += line.count("{") - line.count("}")
if depth == 0 and len(chunk) >= max_statements:
flush()
flush()
return out
# pylint: disable=too-many-public-methods
class EsphomeCore:
def __init__(self):
@@ -1131,29 +1095,14 @@ class EsphomeCore:
@property
def cpp_main_section(self):
from esphome.cpp_generator import ComponentMarker, statement
from esphome.cpp_generator import statement
# Split main_statements at ComponentMarker sentinels and wrap each
# component's group in an IIFE, sub-splitting at 50 statements so
# a single heavy component (e.g. a sensor platform with many
# filter registrations) can't blow the peak chunk frame.
prefix: list[str] = []
components: list[list[str]] = []
current = prefix
main_code = []
for exp in self.main_statements:
if isinstance(exp, ComponentMarker):
current = []
components.append(current)
continue
current.append(str(statement(exp)).rstrip())
if not components:
return "\n".join(prefix) + "\n\n"
pieces = list(prefix)
for body in components:
pieces.extend(_wrap_in_iifes(body, max_statements=50))
return "\n".join(pieces) + "\n\n"
text = str(statement(exp))
text = text.rstrip()
main_code.append(text)
return "\n".join(main_code) + "\n\n"
@property
def cpp_global_section(self):
-3
View File
@@ -217,9 +217,6 @@ void HOT Application::feed_wdt_slow_(uint32_t time) {
// confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded.
arch_feed_wdt();
this->last_wdt_feed_ = time;
#ifdef USE_RUNTIME_STATS
this->wdt_slow_count_++;
#endif
}
#ifdef USE_STATUS_LED
+1 -24
View File
@@ -519,11 +519,6 @@ class Application {
// millis() of most recent status_led dispatch; rate-limits independently of last_wdt_feed_
uint32_t last_status_led_service_{0};
#endif
#ifdef USE_RUNTIME_STATS
// Count of feed_wdt_slow_() invocations since last runtime_stats log. Lets
// stats output report how often the slow path (arch_feed_wdt) actually runs.
uint32_t wdt_slow_count_{0};
#endif
// 2-byte members (grouped together for alignment)
uint16_t dump_config_at_{std::numeric_limits<uint16_t>::max()}; // Index into components_ for dump_config progress
@@ -700,12 +695,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
// so the gate check and WDT feed both reflect actual elapsed time after
// scheduler dispatch, without an extra millis() call.
uint32_t now = this->scheduler_tick_(MillisInternal::get());
#ifdef USE_RUNTIME_STATS
// Capture immediately after scheduler_tick_ returns so the post-scheduler
// feed_wdt_with_time slice can be separated from the scheduler slice in
// the `before` bucket.
uint32_t loop_after_sched_us = micros();
#endif
// Guarantee one WDT feed per tick even when the scheduler had nothing to
// dispatch and the component phase is gated out — covers configs with no
// looping components and no scheduler work (setup() has its own
@@ -776,22 +765,10 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
uint32_t loop_before_overhead_us = loop_before_wall_us > loop_before_scheduled_us
? loop_before_wall_us - static_cast<uint32_t>(loop_before_scheduled_us)
: 0;
// Sub-split of the `before` bucket. `sched_us` covers MillisInternal::get()
// plus scheduler_tick_ (everything before the post-scheduler feed_wdt_with_time),
// with scheduled-component time subtracted out so it reflects scheduler
// infrastructure overhead only. `wdt_us` covers the post-scheduler
// feed_wdt_with_time() call (including any status_led dispatch when the
// STATUS_LED_DISPATCH_INTERVAL_MS gate also elapses on the same call).
uint32_t loop_before_sched_wall_us = loop_after_sched_us - loop_active_start_us;
uint32_t loop_before_sched_us = loop_before_sched_wall_us > loop_before_scheduled_us
? loop_before_sched_wall_us - static_cast<uint32_t>(loop_before_scheduled_us)
: 0;
uint32_t loop_before_wdt_us = loop_before_end_us - loop_after_sched_us;
// tail_us is only defined when Phase B ran; 0 on Phase A-only ticks so the
// stats bucket keeps its "component-phase trailing overhead" meaning.
uint32_t loop_tail_us = do_component_phase ? (loop_now_us - loop_tail_start_us) : 0;
global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us,
loop_before_sched_us, loop_before_wdt_us, loop_tail_us);
global_runtime_stats->record_loop_active(loop_now_us - loop_active_start_us, loop_before_overhead_us, loop_tail_us);
global_runtime_stats->process_pending_stats(now);
}
#endif
-14
View File
@@ -430,20 +430,6 @@ optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
// every loop iteration already performs its own cleanup before the next sleep-
// duration computation happens.
#ifndef ESPHOME_THREAD_SINGLE
// defer() items live in a separate queue that is drained at the top of every
// loop tick via process_defer_queue_(). If any are pending, the next loop
// iteration has work to do right now -- don't let the caller sleep.
if (!this->defer_empty_())
return 0;
#else
// On single-threaded builds, defer() routes through set_timeout(..., 0) which
// stages in to_add_. process_to_add() runs at the top of every scheduler.call(),
// so anything in to_add_ becomes runnable on the next iteration; don't sleep.
if (!this->to_add_empty_())
return 0;
#endif
#ifndef ESPHOME_THREAD_SINGLE
// defer() items live in a separate queue that is drained at the top of every
// loop tick via process_defer_queue_(). If any are pending, the next loop
+2 -27
View File
@@ -434,25 +434,6 @@ class LineComment(Statement):
return "\n".join(parts)
class ComponentMarker(Statement):
"""Chunking-boundary sentinel. ``cpp_main_section`` wraps the
statements between two markers in an IIFE to shorten temporary
lifetimes and bound peak setup-time stack. Emits no C++ output.
Grouping is best-effort: ``flush_tasks`` can interleave coroutines
on ``await``, so a component's later statements may land in another
component's chunk. Safe because every statement either placement-
news into static storage or mutates a file-scope global."""
__slots__ = ("name",)
def __init__(self, name: str):
self.name = name
def __str__(self):
return f"// component-marker: {self.name}"
class ProgmemAssignmentExpression(AssignmentExpression):
__slots__ = ()
@@ -477,13 +458,7 @@ def progmem_array(id_, rhs) -> "MockObj":
rhs = safe_exp(rhs)
obj = MockObj(id_, ".")
assignment = ProgmemAssignmentExpression(id_.type, id_, rhs)
# Emit at file scope, not inside setup(). setup() is split into
# per-component IIFE lambdas; a function-local static declared in one
# lambda is not visible to statements in sibling lambdas that
# reference the same shared table (e.g. two lights sharing a gamma
# lookup). File-scope static constexpr is semantically identical for
# read-only lookup tables.
CORE.add_global(assignment)
CORE.add(assignment)
CORE.register_variable(id_, obj)
return obj
@@ -492,7 +467,7 @@ def static_const_array(id_, rhs) -> "MockObj":
rhs = safe_exp(rhs)
obj = MockObj(id_, ".")
assignment = StaticConstAssignmentExpression(id_.type, id_, rhs)
CORE.add_global(assignment)
CORE.add(assignment)
CORE.register_variable(id_, obj)
return obj
-18
View File
@@ -322,24 +322,6 @@ def resolve_ip_address(
return res
def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str:
"""Build an ``http://host:port/path`` URL for a resolved address.
``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6
literals must be wrapped in brackets in URLs; link-local addresses need a
percent-encoded zone index per RFC 6874.
"""
import socket
ip = sockaddr[0]
if family == socket.AF_INET6:
scope = sockaddr[3] if len(sockaddr) >= 4 else 0
host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]"
else:
host_part = ip
return f"http://{host_part}:{port}{path}"
def sort_ip_addresses(address_list: list[str]) -> list[str]:
"""Takes a list of IP addresses in string form, e.g. from mDNS or MQTT,
and sorts them into the best order to actually try connecting to them.
-43
View File
@@ -1,43 +0,0 @@
"""Shared helpers for the web_server HTTP transports (OTA upload and logs)."""
from __future__ import annotations
from esphome.const import (
CONF_AUTH,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_WEB_SERVER,
)
from esphome.core import CORE, EsphomeError
from esphome.helpers import format_ip_url, resolve_ip_address
from esphome.types import ConfigType
def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]:
"""Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``.
Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and
formats each resolved address into an ``http://host:port/path`` URL via
:func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the
web_server OTA upload and log streaming paths.
"""
addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache)
return [
(sockaddr[0], format_ip_url(family, sockaddr, port, path))
for family, _socktype, _, _, sockaddr in addr_infos
]
def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]:
"""Return ``(port, username, password)`` for the web_server HTTP endpoint.
Reads the port and optional HTTP Basic-auth credentials from the validated
``web_server:`` config, shared by the web_server OTA upload and log
streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent.
"""
web_conf = config.get(CONF_WEB_SERVER)
if not web_conf:
raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.")
auth = web_conf.get(CONF_AUTH) or {}
return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD)
-189
View File
@@ -1,189 +0,0 @@
"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint.
The ``web_server`` component exposes a Server-Sent Events stream at ``/events``
that multiplexes entity state, keepalive pings, and log lines (``event: log``).
This is the logging counterpart to the web_server OTA upload path
(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that
has ``web_server:`` configured but no ``api:``.
Only the ``event: log`` frames are rendered; the payload is the device's
already-formatted, ANSI-colored log line, so it is passed through the same
``LogParser`` + ``safe_print`` path the serial and native-API log viewers use.
The stream is long-lived and the server drops idle connections, so the reader
reconnects automatically until interrupted.
"""
from __future__ import annotations
from datetime import datetime
import logging
import time
from typing import TYPE_CHECKING
import requests
from requests.auth import HTTPBasicAuth
from esphome.core import EsphomeError
from esphome.util import safe_print
from esphome.web_server_helpers import resolve_web_server_urls
if TYPE_CHECKING:
from aioesphomeapi import LogParser
_LOGGER = logging.getLogger(__name__)
EVENTS_PATH = "/events"
# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every
# 10s, so a 30s read timeout tolerates a few missed pings before we treat the
# connection as dead and reconnect.
TIMEOUT = (10.0, 30.0)
# Pause between reconnect attempts so a downed device doesn't spin the CPU.
RECONNECT_DELAY = 1.0
# Upper bound for the exponential backoff applied to consecutive failures, so an
# unreachable host backs off instead of retrying (and logging) once a second.
MAX_RECONNECT_DELAY = 10.0
class WebServerLogsError(EsphomeError):
"""Raised when the web_server log stream cannot be used (e.g. bad auth)."""
def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]:
"""Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint."""
urls: list[tuple[str, str]] = []
seen: set[str] = set()
for host in hosts:
try:
resolved = resolve_web_server_urls(host, port, EVENTS_PATH)
except EsphomeError as err:
_LOGGER.warning("Error resolving IP address of %s: %s", host, err)
continue
for ip, url in resolved:
if url not in seen:
seen.add(url)
urls.append((ip, url))
return urls
def _emit(data_lines: list[str], parser: LogParser) -> None:
"""Render the accumulated ``data:`` lines of one ``event: log`` frame."""
time_ = datetime.now().astimezone()
milliseconds = time_.microsecond // 1000
time_str = (
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]"
)
for line in data_lines:
safe_print(parser.parse_line(line, time_str))
def _consume(response: requests.Response, parser: LogParser) -> None:
"""Parse the SSE stream, rendering only ``event: log`` frames.
Implements the minimal slice of the SSE grammar the ``web_server`` stream
uses: ``field: value`` lines (with one optional leading space after the
colon) accumulated until a blank line dispatches the frame. ``id:``,
``retry:``, and comment (``:``) lines are ignored, as are non-``log``
events (``ping``, ``state``, ...).
"""
event_type = "message"
data_lines: list[str] = []
# Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the
# text/event-stream response has no charset, so requests' decode_unicode
# would fall back to Latin-1 and mojibake UTF-8 log characters.
for raw in response.iter_lines():
line = raw.decode("utf8", "backslashreplace")
if not line:
if event_type == "log" and data_lines:
_emit(data_lines, parser)
event_type = "message"
data_lines = []
continue
if line.startswith(":"):
continue
field, _, value = line.partition(":")
value = value.removeprefix(" ")
if field == "event":
event_type = value
elif field == "data":
data_lines.append(value)
def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool:
"""Connect and stream one session.
Returns ``True`` if a connection was established (even if it later
dropped), ``False`` if the connection attempt itself failed so the caller
can try the next resolved address.
"""
connected = False
_LOGGER.info("Connecting to %s ...", url)
try:
with requests.get(
url,
stream=True,
auth=auth,
timeout=TIMEOUT,
headers={"Accept": "text/event-stream"},
) as response:
if response.status_code == 401:
raise WebServerLogsError(
"Authentication failed (HTTP 401). Check the 'web_server' "
"'auth' username and password."
)
if response.status_code in (403, 404):
# Permanent: the endpoint won't appear on retry (wrong version,
# 'log' disabled, or forbidden). Surface it instead of looping.
raise WebServerLogsError(
f"Device returned HTTP {response.status_code} for "
f"{EVENTS_PATH}; the web_server log stream is unavailable. "
"Ensure 'web_server' is version 2 or higher with 'log' enabled."
)
if response.status_code != 200:
_LOGGER.error(
"Unexpected HTTP %s response from %s", response.status_code, ip
)
return False
connected = True
_LOGGER.info("Connected to %s", ip)
_consume(response, parser)
except requests.RequestException as err:
if connected:
_LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err)
else:
_LOGGER.warning("Could not connect to %s: %s", ip, err)
return connected
def run_logs(
hosts: list[str],
port: int,
username: str | None,
password: str | None,
) -> int:
"""Stream logs from the first reachable host over the web_server SSE feed.
Reconnects automatically when the stream drops and returns ``0`` on
``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits.
"""
from aioesphomeapi import LogParser
auth = HTTPBasicAuth(username, password) if username and password else None
parser = LogParser()
delay = RECONNECT_DELAY
try:
while True:
if not (urls := _build_urls(hosts, port)):
_LOGGER.error("Could not resolve any of: %s", ", ".join(hosts))
connected = False
else:
# ``any`` stops at the first address that connects; when that
# stream drops we reconnect to the same set on the next pass.
connected = any(_stream(url, ip, auth, parser) for ip, url in urls)
# Reset the backoff once we reach the device; otherwise grow it
# (capped) so an unreachable host doesn't retry/log once a second.
delay = (
RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY)
)
time.sleep(delay)
except KeyboardInterrupt:
return 0
+14 -5
View File
@@ -12,14 +12,14 @@ import io
import logging
from pathlib import Path
import secrets
import socket
from typing import BinaryIO
import requests
from requests.auth import HTTPBasicAuth
from esphome.core import EsphomeError
from esphome.helpers import ProgressBar
from esphome.web_server_helpers import resolve_web_server_urls
from esphome.helpers import ProgressBar, resolve_ip_address
_LOGGER = logging.getLogger(__name__)
@@ -95,7 +95,7 @@ def _try_upload(
from esphome.core import CORE
try:
addr_urls = resolve_web_server_urls(host, port, OTA_PATH)
addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache)
except EsphomeError as err:
_LOGGER.error(
"Error resolving IP address of %s. Is it connected to WiFi?", host
@@ -104,7 +104,7 @@ def _try_upload(
_LOGGER.error("(If you know the IP, try --device <IP>)")
raise WebServerOTAError(err) from err
if not addr_urls:
if not addr_infos:
_LOGGER.error("Could not resolve %s", host)
return 1, None
@@ -113,7 +113,16 @@ def _try_upload(
auth = HTTPBasicAuth(username, password) if username and password else None
# Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does.
for ip, url in addr_urls:
for af, _socktype, _, _, sa in addr_infos:
ip = sa[0]
# IPv6 literals must be wrapped in brackets in URLs; link-local
# addresses need a percent-encoded zone index per RFC 6874.
if af == socket.AF_INET6:
scope = sa[3] if len(sa) >= 4 else 0
host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]"
else:
host_part = ip
url = f"http://{host_part}:{port}{OTA_PATH}"
_LOGGER.info("Connecting to %s port %s...", ip, port)
try:
-7
View File
@@ -113,13 +113,6 @@ extends = common:arduino
platform = platformio/espressif8266@4.2.1
platform_packages =
platformio/framework-arduinoespressif8266@~3.30102.0
; Override the ancient tool-esptoolpy ~1.30000.0 pinned by
; platform-espressif8266 4.2.1 with the same 5.x build the pioarduino
; ESP32 platform uses, so switching between esp8266 and esp32 builds
; does not reinstall tool-esptoolpy every time.
; The 0.0.1 path component is pioarduino's stable registry release tag
; (not the tool version); the tool itself is 5.2.0.
pioarduino/tool-esptoolpy@https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.2.0.zip
framework = arduino
lib_deps =
+1 -1
View File
@@ -20,7 +20,7 @@ resvg-py==0.3.3
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
smpclient==7.2.0
smpclient==7.3.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.10.0 # native esp-idf toolchain global cache dir
+2 -2
View File
@@ -676,10 +676,10 @@ class UInt64Type(VarintTypeMixin, TypeInfo):
def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802
if self.mac_address:
return {
**super().RAW_ENCODE_MAP,
**TypeInfo.RAW_ENCODE_MAP,
"encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});",
}
return super().RAW_ENCODE_MAP
return TypeInfo.RAW_ENCODE_MAP
def get_estimated_size(self) -> int:
return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint
+3 -4
View File
@@ -26,12 +26,11 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs"
PLATFORMIO_OPTIONS = {
"build_unflags": [
"-Os", # remove default size-opt
],
"build_flags": [
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
"-Os", # match firmware optimization level (detects inlining regressions)
"-g", # debug symbols for profiling
"-ffunction-sections", # required for dead-code stripping with -Os
"-fdata-sections", # required for dead-code stripping with -Os
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
f"-I{STUBS_DIR}", # stub headers for ESP32-only components
],
+1 -7
View File
@@ -375,17 +375,11 @@ def build_all_include(header_files: list[str] | None = None) -> None:
cmd = ["git", "ls-files", "esphome/**/*.h"]
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
from esphome.writer import ENTITY_TYPES_H_TARGET
# X-macro files are included multiple times with different macro definitions
# and must not be included bare
exclude = {ENTITY_TYPES_H_TARGET}
# Process git output - git already returns paths relative to repo root
header_files = [
line.replace(os.path.sep, "/")
for line in proc.stdout.strip().split("\n")
if line and line.replace(os.path.sep, "/") not in exclude
if line
]
from esphome.writer import ENTITY_TYPES_H_TARGET
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
+38
View File
@@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf(
r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]",
id="ignore_efuse_mac_crc_only_on_esp32",
),
pytest.param(
{
"variant": "esp32",
"board": "esp32dev",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_encryption": {"key_id": 0}},
},
},
r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]",
id="nvs_encryption_unsupported_on_esp32",
),
pytest.param(
{
"variant": "esp32s3",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_encryption": {"key_id": 6}},
},
},
r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]",
id="nvs_encryption_key_id_out_of_range",
),
],
)
def test_esp32_configuration_errors(
@@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig(
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_nvs_encryption_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that nvs_encryption sets the HMAC scheme sdkconfig options."""
generate_main(component_config_path("nvs_encryption_s3.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True
assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True
assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0
# The permanent/irreversible eFuse burn is warned about at config time.
assert "PERMANENT and IRREVERSIBLE" in caplog.text
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
@@ -0,0 +1,26 @@
esphome:
name: test-not-paused
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
widgets:
- obj:
id: root_obj
@@ -0,0 +1,27 @@
esphome:
name: test-paused
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
paused: true
widgets:
- obj:
id: root_obj
+35
View File
@@ -0,0 +1,35 @@
"""Tests for the LVGL ``paused`` option code generation."""
from __future__ import annotations
import re
_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);")
def _extract_set_paused(main_cpp: str) -> list[str]:
"""Return the normalised argument text of every set_paused() call found.
Whitespace within and around the arguments is collapsed so unrelated
code-generation formatting changes don't break these tests.
"""
return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)]
class TestPausedCodeGeneration:
"""Verify that the ``paused`` option drives the set_paused() call."""
def test_paused_true_generates_set_paused(
self, generate_main, component_config_path
):
"""``paused: true`` emits a set_paused(true, false) call."""
main_cpp = generate_main(component_config_path("paused.yaml"))
calls = _extract_set_paused(main_cpp)
assert calls == ["true, false"]
def test_paused_default_omits_set_paused(
self, generate_main, component_config_path
):
"""Without ``paused`` (default false) no set_paused call is generated."""
main_cpp = generate_main(component_config_path("not_paused.yaml"))
assert _extract_set_paused(main_cpp) == []
@@ -0,0 +1,9 @@
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
<<: !include common.yaml
+3 -14
View File
@@ -24,20 +24,6 @@ binary_sensor:
name: Button A checked
widget: button_a
state: checked
- platform: lvgl
id: button_checker
name: LVGL button
widget: button_button
state: checked
on_state:
then:
- lvgl.checkbox.update:
id: checkbox_id
state:
checked: !lambda |-
auto y = x; // block inlining of one line return
return y;
- platform: lvgl
id: button_presser
name: Button pressed
@@ -49,6 +35,9 @@ lvgl:
rotation: 90
log_level: debug
resume_on_input: true
paused: true
update_when_display_idle: true
refresh_interval: 30ms
on_pause:
- logger.log: LVGL is Paused
- lvgl.display.set_rotation: 90
+2 -3
View File
@@ -1,7 +1,8 @@
packages:
lvgl: !include lvgl-package.yaml
lvgl_package: !include lvgl-package.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
lvgl: !include common.yaml
sensor:
- platform: rotary_encoder
@@ -77,5 +78,3 @@ lvgl:
- component.update: tft_display
- delay: 60s
- lvgl.resume:
<<: !include common.yaml
+11
View File
@@ -4,6 +4,11 @@ audio_dac:
i2c_id: i2c_bus
address: 0x4D
bits_per_sample: 32bit
analog_gain: -6db
channel_mix: swapped
volume_min_db: -60dB
volume_max_db: -3dB
enable_pin: GPIO12
output:
- platform: gpio
@@ -22,3 +27,9 @@ binary_sensor:
number: 4
mode:
input: true
switch:
- platform: pcm5122
pcm5122: pcm5122_dac
name: PCM5122 Power Down
power_mode: powerdown
+522 -164
View File
@@ -1,7 +1,14 @@
// Tests for time conversion functions, DST detection, and ESPTime::strptime.
// Tests for the POSIX TZ parser, time conversion functions, and ESPTime::strptime.
//
// These tests cover the permanent timezone functions: epoch_to_local_tm, is_in_dst,
// calculate_dst_transition, and the internal helper functions they depend on.
// Most tests here cover the C++ POSIX TZ string parser (parse_posix_tz), which is
// bridge code for backward compatibility — it will be removed before ESPHome 2026.9.0.
// After https://github.com/esphome/esphome/pull/14233 merges, the parser is solely
// used to handle timezone strings from Home Assistant clients older than 2026.3.0
// that haven't been updated to send the pre-parsed ParsedTimezone protobuf struct.
// See https://github.com/esphome/backlog/issues/91
//
// The epoch_to_local_tm, is_in_dst, and ESPTime::strptime tests cover conversion
// functions that will remain permanently.
// Enable USE_TIME_TIMEZONE for tests
#define USE_TIME_TIMEZONE
@@ -28,83 +35,435 @@ static time_t make_utc(int year, int month, int day, int hour = 0, int min = 0,
return days * 86400 + hour * 3600 + min * 60 + sec;
}
// Helper to build a US Eastern timezone (EST5EDT,M3.2.0/2,M11.1.0/2)
static ParsedTimezone make_us_eastern() {
ParsedTimezone tz{};
tz.std_offset_seconds = 5 * 3600;
tz.dst_offset_seconds = 4 * 3600;
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_start.month = 3;
tz.dst_start.week = 2;
tz.dst_start.day_of_week = 0;
tz.dst_start.time_seconds = 2 * 3600;
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_end.month = 11;
tz.dst_end.week = 1;
tz.dst_end.day_of_week = 0;
tz.dst_end.time_seconds = 2 * 3600;
return tz;
// ============================================================================
// Basic TZ string parsing tests
// ============================================================================
TEST(PosixTzParser, ParseSimpleOffsetEST5) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5", tz));
EXPECT_EQ(tz.std_offset_seconds, 5 * 3600); // +5 hours (west of UTC)
EXPECT_FALSE(tz.has_dst());
}
// Helper to build a US Central timezone (CST6CDT,M3.2.0,M11.1.0)
static ParsedTimezone make_us_central() {
ParsedTimezone tz{};
tz.std_offset_seconds = 6 * 3600;
tz.dst_offset_seconds = 5 * 3600;
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_start.month = 3;
tz.dst_start.week = 2;
tz.dst_start.day_of_week = 0;
tz.dst_start.time_seconds = 2 * 3600;
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_end.month = 11;
tz.dst_end.week = 1;
tz.dst_end.day_of_week = 0;
tz.dst_end.time_seconds = 2 * 3600;
return tz;
TEST(PosixTzParser, ParseNegativeOffsetCET) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("CET-1", tz));
EXPECT_EQ(tz.std_offset_seconds, -1 * 3600); // -1 hour (east of UTC)
EXPECT_FALSE(tz.has_dst());
}
// Helper to build New Zealand timezone (NZST-12NZDT,M9.5.0,M4.1.0/3)
static ParsedTimezone make_new_zealand() {
ParsedTimezone tz{};
tz.std_offset_seconds = -12 * 3600;
tz.dst_offset_seconds = -13 * 3600;
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_start.month = 9;
tz.dst_start.week = 5;
tz.dst_start.day_of_week = 0;
tz.dst_start.time_seconds = 2 * 3600;
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_end.month = 4;
tz.dst_end.week = 1;
tz.dst_end.day_of_week = 0;
tz.dst_end.time_seconds = 3 * 3600;
return tz;
TEST(PosixTzParser, ParseExplicitPositiveOffset) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("TEST+5", tz));
EXPECT_EQ(tz.std_offset_seconds, 5 * 3600);
EXPECT_FALSE(tz.has_dst());
}
// Helper to build Australia/Sydney timezone (AEST-10AEDT,M10.1.0,M4.1.0)
static ParsedTimezone make_australia_sydney() {
ParsedTimezone tz{};
tz.std_offset_seconds = -10 * 3600;
tz.dst_offset_seconds = -11 * 3600;
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_start.month = 10;
tz.dst_start.week = 1;
tz.dst_start.day_of_week = 0;
tz.dst_start.time_seconds = 2 * 3600;
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_end.month = 4;
tz.dst_end.week = 1;
tz.dst_end.day_of_week = 0;
tz.dst_end.time_seconds = 3 * 3600;
return tz;
TEST(PosixTzParser, ParseZeroOffset) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("UTC0", tz));
EXPECT_EQ(tz.std_offset_seconds, 0);
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, ParseUSEasternWithDST) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0,M11.1.0", tz));
EXPECT_EQ(tz.std_offset_seconds, 5 * 3600);
EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600); // Default: STD - 1hr
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.month, 3);
EXPECT_EQ(tz.dst_start.week, 2);
EXPECT_EQ(tz.dst_start.day_of_week, 0); // Sunday
EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // Default 2:00 AM
EXPECT_EQ(tz.dst_end.month, 11);
EXPECT_EQ(tz.dst_end.week, 1);
EXPECT_EQ(tz.dst_end.day_of_week, 0);
}
TEST(PosixTzParser, ParseUSCentralWithTime) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("CST6CDT,M3.2.0/2,M11.1.0/2", tz));
EXPECT_EQ(tz.std_offset_seconds, 6 * 3600);
EXPECT_EQ(tz.dst_offset_seconds, 5 * 3600);
EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600); // 2:00 AM
EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600);
}
TEST(PosixTzParser, ParseEuropeBerlin) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("CET-1CEST,M3.5.0,M10.5.0/3", tz));
EXPECT_EQ(tz.std_offset_seconds, -1 * 3600);
EXPECT_EQ(tz.dst_offset_seconds, -2 * 3600); // Default: STD - 1hr
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.month, 3);
EXPECT_EQ(tz.dst_start.week, 5); // Last week
EXPECT_EQ(tz.dst_end.month, 10);
EXPECT_EQ(tz.dst_end.week, 5); // Last week
EXPECT_EQ(tz.dst_end.time_seconds, 3 * 3600); // 3:00 AM
}
TEST(PosixTzParser, ParseNewZealand) {
ParsedTimezone tz;
// Southern hemisphere - DST starts in Sept, ends in April
ASSERT_TRUE(parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz));
EXPECT_EQ(tz.std_offset_seconds, -12 * 3600);
EXPECT_EQ(tz.dst_offset_seconds, -13 * 3600); // Default: STD - 1hr
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.month, 9); // September
EXPECT_EQ(tz.dst_end.month, 4); // April
}
TEST(PosixTzParser, ParseExplicitDstOffset) {
ParsedTimezone tz;
// Some places have non-standard DST offsets
ASSERT_TRUE(parse_posix_tz("TEST5DST4,M3.2.0,M11.1.0", tz));
EXPECT_EQ(tz.std_offset_seconds, 5 * 3600);
EXPECT_EQ(tz.dst_offset_seconds, 4 * 3600);
EXPECT_TRUE(tz.has_dst());
}
// ============================================================================
// Angle-bracket notation tests (espressif/newlib-esp32#8)
// ============================================================================
TEST(PosixTzParser, ParseAngleBracketPositive) {
// Format: <+07>-7 means UTC+7 (name is "+07", offset is -7 hours east)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("<+07>-7", tz));
EXPECT_EQ(tz.std_offset_seconds, -7 * 3600); // -7 = 7 hours east of UTC
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, ParseAngleBracketNegative) {
// <-03>3 means UTC-3 (name is "-03", offset is 3 hours west)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("<-03>3", tz));
EXPECT_EQ(tz.std_offset_seconds, 3 * 3600);
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, ParseAngleBracketWithDST) {
// <+10>-10<+11>,M10.1.0,M4.1.0/3 (Australia/Sydney style)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("<+10>-10<+11>,M10.1.0,M4.1.0/3", tz));
EXPECT_EQ(tz.std_offset_seconds, -10 * 3600);
EXPECT_EQ(tz.dst_offset_seconds, -11 * 3600);
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.month, 10);
EXPECT_EQ(tz.dst_end.month, 4);
}
TEST(PosixTzParser, ParseAngleBracketNamed) {
// <AEST>-10 (Australian Eastern Standard Time)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("<AEST>-10", tz));
EXPECT_EQ(tz.std_offset_seconds, -10 * 3600);
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, ParseAngleBracketWithMinutes) {
// <+0545>-5:45 (Nepal)
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("<+0545>-5:45", tz));
EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60));
EXPECT_FALSE(tz.has_dst());
}
// ============================================================================
// Half-hour and unusual offset tests
// ============================================================================
TEST(PosixTzParser, ParseOffsetWithMinutesIndia) {
ParsedTimezone tz;
// India: UTC+5:30
ASSERT_TRUE(parse_posix_tz("IST-5:30", tz));
EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 30 * 60));
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, ParseOffsetWithMinutesNepal) {
ParsedTimezone tz;
// Nepal: UTC+5:45
ASSERT_TRUE(parse_posix_tz("NPT-5:45", tz));
EXPECT_EQ(tz.std_offset_seconds, -(5 * 3600 + 45 * 60));
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, ParseOffsetWithSeconds) {
ParsedTimezone tz;
// Unusual but valid: offset with seconds
ASSERT_TRUE(parse_posix_tz("TEST-1:30:30", tz));
EXPECT_EQ(tz.std_offset_seconds, -(1 * 3600 + 30 * 60 + 30));
}
TEST(PosixTzParser, ParseChathamIslands) {
// Chatham Islands: UTC+12:45 with DST
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45", tz));
EXPECT_EQ(tz.std_offset_seconds, -(12 * 3600 + 45 * 60));
EXPECT_EQ(tz.dst_offset_seconds, -(13 * 3600 + 45 * 60));
EXPECT_TRUE(tz.has_dst());
}
// ============================================================================
// Invalid input tests
// ============================================================================
TEST(PosixTzParser, ParseEmptyStringFails) {
ParsedTimezone tz;
EXPECT_FALSE(parse_posix_tz("", tz));
}
TEST(PosixTzParser, ParseNullFails) {
ParsedTimezone tz;
EXPECT_FALSE(parse_posix_tz(nullptr, tz));
}
TEST(PosixTzParser, ParseShortNameFails) {
ParsedTimezone tz;
// TZ name must be at least 3 characters
EXPECT_FALSE(parse_posix_tz("AB5", tz));
}
TEST(PosixTzParser, ParseMissingOffsetFails) {
ParsedTimezone tz;
EXPECT_FALSE(parse_posix_tz("EST", tz));
}
TEST(PosixTzParser, ParseUnterminatedBracketFails) {
ParsedTimezone tz;
EXPECT_FALSE(parse_posix_tz("<+07-7", tz)); // Missing closing >
}
// ============================================================================
// J-format and plain day number tests
// ============================================================================
TEST(PosixTzParser, ParseJFormatBasic) {
ParsedTimezone tz;
// J format: Julian day 1-365, not counting Feb 29
ASSERT_TRUE(parse_posix_tz("EST5EDT,J60,J305", tz));
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP);
EXPECT_EQ(tz.dst_start.day, 60); // March 1
EXPECT_EQ(tz.dst_end.type, DSTRuleType::JULIAN_NO_LEAP);
EXPECT_EQ(tz.dst_end.day, 305); // November 1
}
TEST(PosixTzParser, ParseJFormatWithTime) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5EDT,J60/2,J305/2", tz));
EXPECT_EQ(tz.dst_start.day, 60);
EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600);
EXPECT_EQ(tz.dst_end.day, 305);
EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600);
}
TEST(PosixTzParser, ParsePlainDayNumber) {
ParsedTimezone tz;
// Plain format: day 0-365, counting Feb 29 in leap years
ASSERT_TRUE(parse_posix_tz("EST5EDT,59,304", tz));
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.type, DSTRuleType::DAY_OF_YEAR);
EXPECT_EQ(tz.dst_start.day, 59);
EXPECT_EQ(tz.dst_end.type, DSTRuleType::DAY_OF_YEAR);
EXPECT_EQ(tz.dst_end.day, 304);
}
TEST(PosixTzParser, JFormatInvalidDayZero) {
ParsedTimezone tz;
// J format day must be 1-365, not 0
EXPECT_FALSE(parse_posix_tz("EST5EDT,J0,J305", tz));
}
TEST(PosixTzParser, JFormatInvalidDay366) {
ParsedTimezone tz;
// J format day must be 1-365
EXPECT_FALSE(parse_posix_tz("EST5EDT,J366,J305", tz));
}
TEST(PosixTzParser, ParsePlainDayNumberWithTime) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5EDT,59/3,304/1:30", tz));
EXPECT_EQ(tz.dst_start.day, 59);
EXPECT_EQ(tz.dst_start.time_seconds, 3 * 3600);
EXPECT_EQ(tz.dst_end.day, 304);
EXPECT_EQ(tz.dst_end.time_seconds, 1 * 3600 + 30 * 60);
}
TEST(PosixTzParser, PlainDayInvalidDay366) {
ParsedTimezone tz;
// Plain format day must be 0-365
EXPECT_FALSE(parse_posix_tz("EST5EDT,366,304", tz));
}
// ============================================================================
// Transition time edge cases (POSIX V3 allows -167 to +167 hours)
// ============================================================================
TEST(PosixTzParser, NegativeTransitionTime) {
ParsedTimezone tz;
// Negative transition time: /-1 means 11 PM (23:00) the previous day
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1,M11.1.0/2", tz));
EXPECT_EQ(tz.dst_start.time_seconds, -1 * 3600); // -1 hour = 11 PM previous day
EXPECT_EQ(tz.dst_end.time_seconds, 2 * 3600);
}
TEST(PosixTzParser, NegativeTransitionTimeWithMinutes) {
ParsedTimezone tz;
// /-1:30 means 10:30 PM the previous day
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/-1:30,M11.1.0", tz));
EXPECT_EQ(tz.dst_start.time_seconds, -(1 * 3600 + 30 * 60));
}
TEST(PosixTzParser, LargeTransitionTime) {
ParsedTimezone tz;
// POSIX V3 allows transition times from -167 to +167 hours
// /25 means 1:00 AM the next day
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/25,M11.1.0", tz));
EXPECT_EQ(tz.dst_start.time_seconds, 25 * 3600);
}
TEST(PosixTzParser, MaxTransitionTime167Hours) {
ParsedTimezone tz;
// Maximum allowed transition time per POSIX V3
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/167,M11.1.0", tz));
EXPECT_EQ(tz.dst_start.time_seconds, 167 * 3600);
}
TEST(PosixTzParser, TransitionTimeWithHoursMinutesSeconds) {
ParsedTimezone tz;
ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2:30:45,M11.1.0", tz));
EXPECT_EQ(tz.dst_start.time_seconds, 2 * 3600 + 30 * 60 + 45);
}
// ============================================================================
// Invalid M format tests
// ============================================================================
TEST(PosixTzParser, MFormatInvalidMonth13) {
ParsedTimezone tz;
// Month must be 1-12
EXPECT_FALSE(parse_posix_tz("EST5EDT,M13.1.0,M11.1.0", tz));
}
TEST(PosixTzParser, MFormatInvalidMonth0) {
ParsedTimezone tz;
// Month must be 1-12
EXPECT_FALSE(parse_posix_tz("EST5EDT,M0.1.0,M11.1.0", tz));
}
TEST(PosixTzParser, MFormatInvalidWeek6) {
ParsedTimezone tz;
// Week must be 1-5
EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.6.0,M11.1.0", tz));
}
TEST(PosixTzParser, MFormatInvalidWeek0) {
ParsedTimezone tz;
// Week must be 1-5
EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.0.0,M11.1.0", tz));
}
TEST(PosixTzParser, MFormatInvalidDayOfWeek7) {
ParsedTimezone tz;
// Day of week must be 0-6
EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.7,M11.1.0", tz));
}
TEST(PosixTzParser, MissingEndRule) {
ParsedTimezone tz;
// POSIX requires both start and end rules if any rules are specified
EXPECT_FALSE(parse_posix_tz("EST5EDT,M3.2.0", tz));
}
TEST(PosixTzParser, MissingEndRuleJFormat) {
ParsedTimezone tz;
// POSIX requires both start and end rules if any rules are specified
EXPECT_FALSE(parse_posix_tz("EST5EDT,J60", tz));
}
TEST(PosixTzParser, MissingEndRulePlainDay) {
ParsedTimezone tz;
// POSIX requires both start and end rules if any rules are specified
EXPECT_FALSE(parse_posix_tz("EST5EDT,60", tz));
}
TEST(PosixTzParser, LowercaseMFormat) {
ParsedTimezone tz;
// Lowercase 'm' should be accepted
ASSERT_TRUE(parse_posix_tz("EST5EDT,m3.2.0,m11.1.0", tz));
EXPECT_TRUE(tz.has_dst());
EXPECT_EQ(tz.dst_start.month, 3);
EXPECT_EQ(tz.dst_end.month, 11);
}
TEST(PosixTzParser, LowercaseJFormat) {
ParsedTimezone tz;
// Lowercase 'j' should be accepted
ASSERT_TRUE(parse_posix_tz("EST5EDT,j60,j305", tz));
EXPECT_EQ(tz.dst_start.type, DSTRuleType::JULIAN_NO_LEAP);
EXPECT_EQ(tz.dst_start.day, 60);
}
TEST(PosixTzParser, DstNameWithoutRules) {
ParsedTimezone tz;
// DST name present but no rules - treat as no DST since we can't determine transitions
ASSERT_TRUE(parse_posix_tz("EST5EDT", tz));
EXPECT_FALSE(tz.has_dst());
EXPECT_EQ(tz.std_offset_seconds, 5 * 3600);
}
TEST(PosixTzParser, TrailingCharactersIgnored) {
ParsedTimezone tz;
// Trailing characters after valid TZ should be ignored (parser stops at end of valid input)
// This matches libc behavior
ASSERT_TRUE(parse_posix_tz("EST5 extra garbage here", tz));
EXPECT_EQ(tz.std_offset_seconds, 5 * 3600);
EXPECT_FALSE(tz.has_dst());
}
TEST(PosixTzParser, PlainDay365LeapYear) {
// Day 365 in leap year is Dec 31
int month, day;
internal::day_of_year_to_month_day(365, 2024, month, day);
EXPECT_EQ(month, 12);
EXPECT_EQ(day, 31);
}
TEST(PosixTzParser, PlainDay364NonLeapYear) {
// Day 364 (0-indexed) is Dec 31 in non-leap year (last valid day)
int month, day;
internal::day_of_year_to_month_day(364, 2025, month, day);
EXPECT_EQ(month, 12);
EXPECT_EQ(day, 31);
}
// ============================================================================
// Large offset tests
// ============================================================================
TEST(PosixTzParser, MaxOffset14Hours) {
ParsedTimezone tz;
// Line Islands (Kiribati) is UTC+14, the maximum offset
ASSERT_TRUE(parse_posix_tz("<+14>-14", tz));
EXPECT_EQ(tz.std_offset_seconds, -14 * 3600);
}
TEST(PosixTzParser, MaxNegativeOffset12Hours) {
ParsedTimezone tz;
// Baker Island is UTC-12
ASSERT_TRUE(parse_posix_tz("<-12>12", tz));
EXPECT_EQ(tz.std_offset_seconds, 12 * 3600);
}
// ============================================================================
// Helper function tests
// ============================================================================
TEST(PosixTz, JulianDay60IsMarch1) {
TEST(PosixTzParser, JulianDay60IsMarch1) {
// J60 is always March 1 (J format ignores leap years by design)
int month, day;
internal::julian_to_month_day(60, month, day);
@@ -112,7 +471,7 @@ TEST(PosixTz, JulianDay60IsMarch1) {
EXPECT_EQ(day, 1);
}
TEST(PosixTz, DayOfYear59DiffersByLeap) {
TEST(PosixTzParser, DayOfYear59DiffersByLeap) {
int month, day;
// Day 59 in leap year is Feb 29
internal::day_of_year_to_month_day(59, 2024, month, day);
@@ -124,7 +483,7 @@ TEST(PosixTz, DayOfYear59DiffersByLeap) {
EXPECT_EQ(day, 1);
}
TEST(PosixTz, DayOfWeekKnownDates) {
TEST(PosixTzParser, DayOfWeekKnownDates) {
// January 1, 1970 was Thursday (4)
EXPECT_EQ(internal::day_of_week(1970, 1, 1), 4);
// January 1, 2000 was Saturday (6)
@@ -133,56 +492,56 @@ TEST(PosixTz, DayOfWeekKnownDates) {
EXPECT_EQ(internal::day_of_week(2026, 3, 8), 0);
}
TEST(PosixTz, LeapYearDetection) {
TEST(PosixTzParser, LeapYearDetection) {
EXPECT_FALSE(internal::is_leap_year(1900)); // Divisible by 100 but not 400
EXPECT_TRUE(internal::is_leap_year(2000)); // Divisible by 400
EXPECT_TRUE(internal::is_leap_year(2024)); // Divisible by 4
EXPECT_FALSE(internal::is_leap_year(2025)); // Not divisible by 4
}
TEST(PosixTz, JulianDay1IsJan1) {
TEST(PosixTzParser, JulianDay1IsJan1) {
int month, day;
internal::julian_to_month_day(1, month, day);
EXPECT_EQ(month, 1);
EXPECT_EQ(day, 1);
}
TEST(PosixTz, JulianDay31IsJan31) {
TEST(PosixTzParser, JulianDay31IsJan31) {
int month, day;
internal::julian_to_month_day(31, month, day);
EXPECT_EQ(month, 1);
EXPECT_EQ(day, 31);
}
TEST(PosixTz, JulianDay32IsFeb1) {
TEST(PosixTzParser, JulianDay32IsFeb1) {
int month, day;
internal::julian_to_month_day(32, month, day);
EXPECT_EQ(month, 2);
EXPECT_EQ(day, 1);
}
TEST(PosixTz, JulianDay59IsFeb28) {
TEST(PosixTzParser, JulianDay59IsFeb28) {
int month, day;
internal::julian_to_month_day(59, month, day);
EXPECT_EQ(month, 2);
EXPECT_EQ(day, 28);
}
TEST(PosixTz, JulianDay365IsDec31) {
TEST(PosixTzParser, JulianDay365IsDec31) {
int month, day;
internal::julian_to_month_day(365, month, day);
EXPECT_EQ(month, 12);
EXPECT_EQ(day, 31);
}
TEST(PosixTz, DayOfYear0IsJan1) {
TEST(PosixTzParser, DayOfYear0IsJan1) {
int month, day;
internal::day_of_year_to_month_day(0, 2025, month, day);
EXPECT_EQ(month, 1);
EXPECT_EQ(day, 1);
}
TEST(PosixTz, DaysInMonthRegular) {
TEST(PosixTzParser, DaysInMonthRegular) {
// Test all 12 months to ensure switch coverage
EXPECT_EQ(internal::days_in_month(2025, 1), 31); // Jan - default case
EXPECT_EQ(internal::days_in_month(2025, 2), 28); // Feb - case 2
@@ -198,32 +557,19 @@ TEST(PosixTz, DaysInMonthRegular) {
EXPECT_EQ(internal::days_in_month(2025, 12), 31); // Dec - default case
}
TEST(PosixTz, DaysInMonthLeapYear) {
TEST(PosixTzParser, DaysInMonthLeapYear) {
EXPECT_EQ(internal::days_in_month(2024, 2), 29);
EXPECT_EQ(internal::days_in_month(2025, 2), 28);
}
TEST(PosixTz, PlainDay365LeapYear) {
int month, day;
internal::day_of_year_to_month_day(365, 2024, month, day);
EXPECT_EQ(month, 12);
EXPECT_EQ(day, 31);
}
TEST(PosixTz, PlainDay364NonLeapYear) {
int month, day;
internal::day_of_year_to_month_day(364, 2025, month, day);
EXPECT_EQ(month, 12);
EXPECT_EQ(day, 31);
}
// ============================================================================
// DST transition calculation tests
// ============================================================================
TEST(PosixTz, DstStartUSEastern2026) {
TEST(PosixTzParser, DstStartUSEastern2026) {
// March 8, 2026 is 2nd Sunday of March
auto tz = make_us_eastern();
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
time_t dst_start = internal::calculate_dst_transition(2026, tz.dst_start, tz.std_offset_seconds);
struct tm tm;
@@ -236,9 +582,10 @@ TEST(PosixTz, DstStartUSEastern2026) {
EXPECT_EQ(tm.tm_hour, 7); // 7:00 UTC = 2:00 EST
}
TEST(PosixTz, DstEndUSEastern2026) {
TEST(PosixTzParser, DstEndUSEastern2026) {
// November 1, 2026 is 1st Sunday of November
auto tz = make_us_eastern();
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
time_t dst_end = internal::calculate_dst_transition(2026, tz.dst_end, tz.dst_offset_seconds);
struct tm tm;
@@ -251,7 +598,7 @@ TEST(PosixTz, DstEndUSEastern2026) {
EXPECT_EQ(tm.tm_hour, 6); // 6:00 UTC = 2:00 EDT
}
TEST(PosixTz, LastSundayOfMarch2026) {
TEST(PosixTzParser, LastSundayOfMarch2026) {
// Europe: M3.5.0 = last Sunday of March = March 29, 2026
DSTRule rule{};
rule.type = DSTRuleType::MONTH_WEEK_DAY;
@@ -266,7 +613,7 @@ TEST(PosixTz, LastSundayOfMarch2026) {
EXPECT_EQ(tm.tm_wday, 0); // Sunday
}
TEST(PosixTz, LastSundayOfOctober2026) {
TEST(PosixTzParser, LastSundayOfOctober2026) {
// Europe: M10.5.0 = last Sunday of October = October 25, 2026
DSTRule rule{};
rule.type = DSTRuleType::MONTH_WEEK_DAY;
@@ -281,7 +628,7 @@ TEST(PosixTz, LastSundayOfOctober2026) {
EXPECT_EQ(tm.tm_wday, 0); // Sunday
}
TEST(PosixTz, FirstSundayOfApril2026) {
TEST(PosixTzParser, FirstSundayOfApril2026) {
// April 5, 2026 is 1st Sunday
DSTRule rule{};
rule.type = DSTRuleType::MONTH_WEEK_DAY;
@@ -300,39 +647,46 @@ TEST(PosixTz, FirstSundayOfApril2026) {
// DST detection tests
// ============================================================================
TEST(PosixTz, IsInDstUSEasternSummer) {
auto tz = make_us_eastern();
TEST(PosixTzParser, IsInDstUSEasternSummer) {
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
// July 4, 2026 12:00 UTC - definitely in DST
time_t summer = make_utc(2026, 7, 4, 12);
EXPECT_TRUE(is_in_dst(summer, tz));
}
TEST(PosixTz, IsInDstUSEasternWinter) {
auto tz = make_us_eastern();
TEST(PosixTzParser, IsInDstUSEasternWinter) {
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
// January 15, 2026 12:00 UTC - definitely not in DST
time_t winter = make_utc(2026, 1, 15, 12);
EXPECT_FALSE(is_in_dst(winter, tz));
}
TEST(PosixTz, IsInDstNoDstTimezone) {
// India: IST-5:30 (no DST)
ParsedTimezone tz{};
tz.std_offset_seconds = -(5 * 3600 + 30 * 60);
// No DST rules
TEST(PosixTzParser, IsInDstNoDstTimezone) {
ParsedTimezone tz;
parse_posix_tz("IST-5:30", tz);
// July 15, 2026 12:00 UTC
time_t epoch = make_utc(2026, 7, 15, 12);
EXPECT_FALSE(is_in_dst(epoch, tz));
}
TEST(PosixTz, SouthernHemisphereDstSummer) {
auto tz = make_new_zealand();
TEST(PosixTzParser, SouthernHemisphereDstSummer) {
ParsedTimezone tz;
parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz);
// December 15, 2025 12:00 UTC - summer in NZ, should be in DST
time_t nz_summer = make_utc(2025, 12, 15, 12);
EXPECT_TRUE(is_in_dst(nz_summer, tz));
}
TEST(PosixTz, SouthernHemisphereDstWinter) {
auto tz = make_new_zealand();
TEST(PosixTzParser, SouthernHemisphereDstWinter) {
ParsedTimezone tz;
parse_posix_tz("NZST-12NZDT,M9.5.0,M4.1.0/3", tz);
// July 15, 2026 12:00 UTC - winter in NZ, should NOT be in DST
time_t nz_winter = make_utc(2026, 7, 15, 12);
EXPECT_FALSE(is_in_dst(nz_winter, tz));
@@ -342,8 +696,9 @@ TEST(PosixTz, SouthernHemisphereDstWinter) {
// epoch_to_local_tm tests
// ============================================================================
TEST(PosixTz, EpochToLocalBasic) {
ParsedTimezone tz{}; // UTC
TEST(PosixTzParser, EpochToLocalBasic) {
ParsedTimezone tz;
parse_posix_tz("UTC0", tz);
time_t epoch = 0; // Jan 1, 1970 00:00:00 UTC
struct tm local;
@@ -354,8 +709,9 @@ TEST(PosixTz, EpochToLocalBasic) {
EXPECT_EQ(local.tm_hour, 0);
}
TEST(PosixTz, EpochToLocalNegativeEpoch) {
ParsedTimezone tz{}; // UTC
TEST(PosixTzParser, EpochToLocalNegativeEpoch) {
ParsedTimezone tz;
parse_posix_tz("UTC0", tz);
// Dec 31, 1969 23:59:59 UTC (1 second before epoch)
time_t epoch = -1;
@@ -369,15 +725,15 @@ TEST(PosixTz, EpochToLocalNegativeEpoch) {
EXPECT_EQ(local.tm_sec, 59);
}
TEST(PosixTz, EpochToLocalNullTmFails) {
ParsedTimezone tz{};
TEST(PosixTzParser, EpochToLocalNullTmFails) {
ParsedTimezone tz;
parse_posix_tz("UTC0", tz);
EXPECT_FALSE(epoch_to_local_tm(0, tz, nullptr));
}
TEST(PosixTz, EpochToLocalWithOffset) {
// EST5 (UTC-5, no DST)
ParsedTimezone tz{};
tz.std_offset_seconds = 5 * 3600;
TEST(PosixTzParser, EpochToLocalWithOffset) {
ParsedTimezone tz;
parse_posix_tz("EST5", tz); // UTC-5
// Jan 1, 2026 05:00:00 UTC should be Jan 1, 2026 00:00:00 EST
time_t utc_epoch = make_utc(2026, 1, 1, 5);
@@ -389,8 +745,9 @@ TEST(PosixTz, EpochToLocalWithOffset) {
EXPECT_EQ(local.tm_isdst, 0);
}
TEST(PosixTz, EpochToLocalDstTransition) {
auto tz = make_us_eastern();
TEST(PosixTzParser, EpochToLocalDstTransition) {
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
// July 4, 2026 16:00 UTC = 12:00 EDT (noon)
time_t utc_epoch = make_utc(2026, 7, 4, 16);
@@ -601,8 +958,10 @@ INSTANTIATE_TEST_SUITE_P(AustraliaSydney, LibcVerificationTest,
// DST boundary edge cases
// ============================================================================
TEST(PosixTz, DstBoundaryJustBeforeSpringForward) {
auto tz = make_us_eastern();
TEST(PosixTzParser, DstBoundaryJustBeforeSpringForward) {
// Test 1 second before DST starts
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
// March 8, 2026 06:59:59 UTC = 01:59:59 EST (1 second before spring forward)
time_t before_epoch = make_utc(2026, 3, 8, 6, 59, 59);
@@ -613,8 +972,10 @@ TEST(PosixTz, DstBoundaryJustBeforeSpringForward) {
EXPECT_TRUE(is_in_dst(after_epoch, tz));
}
TEST(PosixTz, DstBoundaryJustBeforeFallBack) {
auto tz = make_us_eastern();
TEST(PosixTzParser, DstBoundaryJustBeforeFallBack) {
// Test 1 second before DST ends
ParsedTimezone tz;
parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz);
// November 1, 2026 05:59:59 UTC = 01:59:59 EDT (1 second before fall back)
time_t before_epoch = make_utc(2026, 11, 1, 5, 59, 59);
@@ -741,6 +1102,7 @@ TEST(ESPTimeStrptime, PartialTimeFails) {
TEST(ESPTimeStrptime, ExtraCharactersFails) {
ESPTime t{};
// Full datetime with extra characters should fail
EXPECT_FALSE(ESPTime::strptime("2026-03-15 14:30:45x", 20, t));
}
@@ -761,12 +1123,6 @@ TEST(ESPTimeStrptime, LeadingZeroTime) {
// recalc_timestamp_local() tests - verify behavior matches libc mktime()
// ============================================================================
} // namespace esphome::testing
namespace esphome::time::testing {
using esphome::ESPTime;
// Helper to call libc mktime with same fields
static time_t libc_mktime(int year, int month, int day, int hour, int min, int sec) {
struct tm tm {};
@@ -798,7 +1154,8 @@ TEST(RecalcTimestampLocal, NormalTimeMatchesLibc) {
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
setenv("TZ", tz_str, 1);
tzset();
auto tz = make_us_central();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Test a normal time in winter (no DST)
@@ -820,7 +1177,8 @@ TEST(RecalcTimestampLocal, SpringForwardSkippedHour) {
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
setenv("TZ", tz_str, 1);
tzset();
auto tz = make_us_central();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Test time before the transition (1:30 AM CST exists)
@@ -846,7 +1204,8 @@ TEST(RecalcTimestampLocal, FallBackRepeatedHour) {
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
setenv("TZ", tz_str, 1);
tzset();
auto tz = make_us_central();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Test time before the transition (midnight CDT)
@@ -873,7 +1232,8 @@ TEST(RecalcTimestampLocal, SouthernHemisphereDST) {
const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0";
setenv("TZ", tz_str, 1);
tzset();
auto tz = make_australia_sydney();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Test winter time (July - no DST in southern hemisphere)
@@ -893,7 +1253,8 @@ TEST(RecalcTimestampLocal, ExactTransitionBoundary) {
const char *tz_str = "CST6CDT,M3.2.0,M11.1.0";
setenv("TZ", tz_str, 1);
tzset();
auto tz = make_us_central();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// 1:59:59 AM CST - last second before transition (still standard time)
@@ -918,19 +1279,8 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) {
const char *tz_str = "CST6CDT,M3.2.0/3,M11.1.0/3";
setenv("TZ", tz_str, 1);
tzset();
ParsedTimezone tz{};
tz.std_offset_seconds = 6 * 3600;
tz.dst_offset_seconds = 5 * 3600;
tz.dst_start.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_start.month = 3;
tz.dst_start.week = 2;
tz.dst_start.day_of_week = 0;
tz.dst_start.time_seconds = 3 * 3600;
tz.dst_end.type = DSTRuleType::MONTH_WEEK_DAY;
tz.dst_end.month = 11;
tz.dst_end.week = 1;
tz.dst_end.day_of_week = 0;
tz.dst_end.time_seconds = 3 * 3600;
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// 2:30 AM should still be standard time (transition at 3:00 AM)
@@ -1004,7 +1354,8 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) {
const char *tz_str = "AEST-10AEDT,M10.1.0,M4.1.0";
setenv("TZ", tz_str, 1);
tzset();
auto tz = make_australia_sydney();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Dec 31, 2025 at 23:30 - DST should be active
@@ -1029,7 +1380,8 @@ TEST(RecalcTimestampLocal, YearBoundaryDST) {
// ============================================================================
TEST(TimezoneOffset, NoTimezone) {
ParsedTimezone tz{};
// When no timezone is set, offset should be 0
time::ParsedTimezone tz{};
set_global_tz(tz);
int32_t offset = ESPTime::timezone_offset();
@@ -1037,28 +1389,34 @@ TEST(TimezoneOffset, NoTimezone) {
}
TEST(TimezoneOffset, FixedOffsetPositive) {
// India: IST-5:30 (no DST)
ParsedTimezone tz{};
tz.std_offset_seconds = -(5 * 3600 + 30 * 60);
// India: UTC+5:30 (no DST)
const char *tz_str = "IST-5:30";
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
int32_t offset = ESPTime::timezone_offset();
// Offset should be +5:30 = 19800 seconds (to add to UTC to get local)
EXPECT_EQ(offset, 5 * 3600 + 30 * 60);
}
TEST(TimezoneOffset, FixedOffsetNegative) {
// EST5 (no DST)
ParsedTimezone tz{};
tz.std_offset_seconds = 5 * 3600;
// US Eastern Standard Time: UTC-5 (testing without DST rules)
const char *tz_str = "EST5";
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
int32_t offset = ESPTime::timezone_offset();
// Offset should be -5 hours = -18000 seconds
EXPECT_EQ(offset, -5 * 3600);
}
TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) {
// US Eastern with DST
auto tz = make_us_eastern();
const char *tz_str = "EST5EDT,M3.2.0,M11.1.0";
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Get current time and check offset matches expected based on DST status
@@ -1066,7 +1424,7 @@ TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) {
int32_t offset = ESPTime::timezone_offset();
// Verify offset matches what is_in_dst says
if (is_in_dst(now, tz)) {
if (time::is_in_dst(now, tz)) {
// During DST, offset should be -4 hours (EDT)
EXPECT_EQ(offset, -4 * 3600);
} else {
@@ -1075,4 +1433,4 @@ TEST(TimezoneOffset, WithDstReturnsCorrectOffsetBasedOnCurrentTime) {
}
}
} // namespace esphome::time::testing
} // namespace esphome::testing
@@ -1,12 +1,6 @@
wifi:
min_auth_mode: WPA2
post_connect_roaming: true
manual_ip:
static_ip: 192.168.1.100
gateway: 192.168.1.1
subnet: 255.255.255.0
dns1: 1.1.1.1
dns2: 8.8.8.8
phy_mode: 11G
packages:
@@ -1,59 +0,0 @@
esphome:
name: test-user-services-union
friendly_name: Test User Services Union Storage
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
level: DEBUG
wifi:
ssid: "test"
password: "password"
api:
actions:
# Test service with no arguments
- action: test_no_args
then:
- logger.log: "No args service called"
# Test service with one argument
- action: test_one_arg
variables:
value: int
then:
- logger.log:
format: "One arg service: %d"
args: [value]
# Test service with multiple arguments of different types
- action: test_multi_args
variables:
int_val: int
float_val: float
str_val: string
bool_val: bool
then:
- logger.log:
format: "Multi args: %d, %.2f, %s, %d"
args: [int_val, float_val, str_val.c_str(), bool_val]
# Test service with max typical arguments
- action: test_many_args
variables:
arg1: int
arg2: int
arg3: int
arg4: string
arg5: float
then:
- logger.log: "Many args service called"
binary_sensor:
- platform: template
name: "Test Binary Sensor"
id: test_sensor
@@ -33,8 +33,8 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() {
// Cancel all of them to mark for removal
ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup...");
int cancelled_count = 0;
for (int i = 0; i < 25; i++) {
if (App.scheduler.cancel_timeout(this, BULK_TIMEOUT_NAMES[i])) {
for (const char *name : BULK_TIMEOUT_NAMES) {
if (App.scheduler.cancel_timeout(this, name)) {
cancelled_count++;
}
}
+43
View File
@@ -322,6 +322,49 @@ async def test_light_calls(
assert state.state is True
assert state.brightness == pytest.approx(0.75)
# Test 31: Setting brightness to 0 without an explicit state implicitly turns
# the light off; turning it back on (without an explicit brightness) then
# restores full brightness so the light is visible again.
client.light_command(key=rgbcw_light.key, state=True, brightness=0.5)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(0.5)
# Brightness 0 with no explicit state -> implicit turn-off
client.light_command(key=rgbcw_light.key, brightness=0.0)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is False
assert state.brightness == pytest.approx(0.0)
# Turning on without an explicit brightness restores it to full brightness
client.light_command(key=rgbcw_light.key, state=True)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
# Test 31b: An explicit turn-on with brightness 0 still resets to full
# brightness - a turn-on must never leave the light on-but-invisible. This
# is the same path the restore logic exercises (set_state(true) +
# set_brightness(0) from a persisted brightness=0 turn-off).
client.light_command(key=rgbcw_light.key, state=True, brightness=0.0)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
# Test 32: Turning a light on when it already has nonzero brightness leaves
# the brightness unchanged (the reset only happens when brightness is 0).
client.light_command(key=rgbcw_light.key, state=True, brightness=0.4)
state = await wait_for_state_change(rgbcw_light.key)
assert state.brightness == pytest.approx(0.4)
client.light_command(key=rgbcw_light.key, state=False)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is False
client.light_command(key=rgbcw_light.key, state=True)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(0.4)
# Final cleanup - turn all lights off
for light in lights:
client.light_command(
+18 -18
View File
@@ -24,24 +24,6 @@ def _seed_etag(cache_file: Path, etag: str) -> Path:
return sidecar
@pytest.fixture
def mock_download_content() -> MagicMock:
"""Patch `external_files.download_content` for tests that exercise the
parallel batch helper without doing real I/O.
"""
with patch("esphome.external_files.download_content") as m:
yield m
@pytest.fixture
def mock_download_content_many() -> MagicMock:
"""Patch `external_files.download_content_many` for tests that exercise
the URL-collection helper without dispatching to the thread pool.
"""
with patch("esphome.external_files.download_content_many") as m:
yield m
@pytest.fixture
def mock_requests_head() -> MagicMock:
"""Patch `external_files.requests.head` so the conditional HEAD-request
@@ -78,6 +60,24 @@ def mock_write_file() -> MagicMock:
yield m
@pytest.fixture
def mock_download_content() -> MagicMock:
"""Patch `external_files.download_content` for tests that exercise the
parallel batch helper without doing real I/O.
"""
with patch("esphome.external_files.download_content") as m:
yield m
@pytest.fixture
def mock_download_content_many() -> MagicMock:
"""Patch `external_files.download_content_many` for tests that exercise
the URL-collection helper without dispatching to the thread pool.
"""
with patch("esphome.external_files.download_content_many") as m:
yield m
def test_compute_local_file_dir(setup_core: Path) -> None:
"""Test compute_local_file_dir creates and returns correct path."""
domain = "font"
+1 -17
View File
@@ -14,7 +14,7 @@ import pytest
from esphome import helpers
from esphome.address_cache import AddressCache
from esphome.core import CORE, EsphomeError
from esphome.helpers import ProgressBar, format_ip_url
from esphome.helpers import ProgressBar
@pytest.mark.parametrize(
@@ -135,22 +135,6 @@ def test_is_ip_address__invalid(host):
assert actual is False
@pytest.mark.parametrize(
("family", "sockaddr", "expected"),
(
(socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"),
(socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"),
(
socket.AF_INET6,
("fe80::1", 8080, 0, 7),
"http://[fe80::1%257]:8080/events",
),
),
)
def test_format_ip_url(family, sockaddr, expected):
assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected
@settings(deadline=None)
@given(value=ip_addresses(v=4).map(str))
def test_is_ip_address__valid(value):
-133
View File
@@ -50,7 +50,6 @@ from esphome.__main__ import (
has_non_ip_address,
has_ota,
has_resolvable_address,
has_web_server_logging,
has_web_server_ota,
mqtt_get_ip,
parse_args,
@@ -74,7 +73,6 @@ from esphome.const import (
CONF_DISABLED,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -89,7 +87,6 @@ from esphome.const import (
CONF_TOPIC,
CONF_USE_ADDRESS,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WIFI,
KEY_CORE,
@@ -771,30 +768,6 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_logging_web_server_only_ip() -> None:
"""A web_server-only device with a static IP resolves to that IP for logs."""
setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100")
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.LOGGING,
)
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_logging_web_server_only_mdns() -> None:
"""A web_server-only device with a .local name resolves to that hostname."""
setup_core(config={CONF_WEB_SERVER: {}}, address="test.local")
result = choose_upload_log_host(
default="OTA",
check_default=None,
purpose=Purpose.LOGGING,
)
assert result == ["test.local"]
def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None:
"""A resolvable device with only ota: fails logs with a missing-api message."""
setup_core(
@@ -834,17 +807,6 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None:
assert "set 'use_address'" in msg
def test_unresolved_default_error_logging_suggests_web_server() -> None:
"""The missing-api log message lists web_server among the remediations."""
setup_core(
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100"
)
msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"])
assert "no 'api:' component is configured" in msg
assert "'web_server:'" in msg
def test_unresolved_default_error_upload_with_ota_is_generic() -> None:
"""With ota: present the upload error stays generic, not transport-specific."""
setup_core(
@@ -2518,30 +2480,6 @@ def test_has_web_server_ota_returns_false_without_config() -> None:
assert has_ota() is True
def test_has_web_server_logging_default() -> None:
"""has_web_server_logging is True for a default web_server (v2, log on)."""
setup_core(config={CONF_WEB_SERVER: {}})
assert has_web_server_logging() is True
def test_has_web_server_logging_without_config() -> None:
"""has_web_server_logging is False when web_server is not configured."""
setup_core(config={CONF_API: {}})
assert has_web_server_logging() is False
def test_has_web_server_logging_v1_has_no_events_stream() -> None:
"""has_web_server_logging is False for v1, which has no /events endpoint."""
setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}})
assert has_web_server_logging() is False
def test_has_web_server_logging_respects_log_disabled() -> None:
"""has_web_server_logging is False when the web_server log option is off."""
setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}})
assert has_web_server_logging() is False
def test_upload_program_web_server_only_auto_dispatches(
mock_run_web_server_ota: Mock,
mock_run_ota: Mock,
@@ -3111,77 +3049,6 @@ def test_show_logs_network_with_mqtt_only(
)
@patch("esphome.web_server_logs.run_logs")
def test_show_logs_web_server(
mock_run_logs: Mock,
) -> None:
"""A web_server-only device streams logs over the HTTP SSE endpoint."""
setup_core(
config={
"logger": {},
CONF_WEB_SERVER: {CONF_PORT: 80},
# No API or MQTT configured
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"])
assert result == 0
mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None)
@patch("esphome.web_server_logs.run_logs")
def test_show_logs_web_server_with_auth_and_port(
mock_run_logs: Mock,
) -> None:
"""web_server port and basic-auth credentials are forwarded to the streamer."""
setup_core(
config={
"logger": {},
CONF_WEB_SERVER: {
CONF_PORT: 8080,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"},
},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"])
assert result == 0
mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret")
@patch("esphome.web_server_logs.run_logs")
@patch("esphome.mqtt.show_logs")
def test_show_logs_mqtt_preferred_over_web_server(
mock_mqtt_show_logs: Mock,
mock_run_logs: Mock,
) -> None:
"""With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server)."""
setup_core(
config={
"logger": {},
"mqtt": {CONF_BROKER: "mqtt.local"},
CONF_WEB_SERVER: {CONF_PORT: 80},
},
platform=PLATFORM_ESP32,
)
mock_mqtt_show_logs.return_value = 0
args = MockArgs(
topic="esphome/logs", username="user", password="pass", client_id="client"
)
result = show_logs(CORE.config, args, ["192.168.1.100"])
assert result == 0
mock_mqtt_show_logs.assert_called_once()
mock_run_logs.assert_not_called()
def test_show_logs_no_method_configured() -> None:
"""Test show_logs when no remote logging method is configured."""
setup_core(
@@ -1,64 +0,0 @@
"""Unit tests for esphome.web_server_helpers module."""
from __future__ import annotations
import socket
import pytest
from esphome.const import (
CONF_AUTH,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_WEB_SERVER,
)
from esphome.core import EsphomeError
from esphome.web_server_helpers import (
get_web_server_connection,
resolve_web_server_urls,
)
def test_resolve_web_server_urls_maps_ipv4_and_ipv6(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Each resolved address becomes an (ip, url) pair with IPv6 bracketing."""
addr_infos = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)),
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)),
]
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address",
lambda *args, **kwargs: addr_infos,
)
assert resolve_web_server_urls("dev.local", 80, "/events") == [
("192.168.1.5", "http://192.168.1.5:80/events"),
("fe80::1", "http://[fe80::1%257]:80/events"),
]
def test_get_web_server_connection_without_auth() -> None:
"""Port is returned and credentials are None when no auth is configured."""
config = {CONF_WEB_SERVER: {CONF_PORT: 80}}
assert get_web_server_connection(config) == (80, None, None)
def test_get_web_server_connection_with_auth() -> None:
"""Port and HTTP Basic credentials are returned when auth is configured."""
config = {
CONF_WEB_SERVER: {
CONF_PORT: 8080,
CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"},
}
}
assert get_web_server_connection(config) == (8080, "admin", "secret")
def test_get_web_server_connection_missing_component() -> None:
"""A config without web_server raises a clear error."""
with pytest.raises(EsphomeError, match="web_server.*not configured"):
get_web_server_connection({})
-397
View File
@@ -1,397 +0,0 @@
"""Unit tests for esphome.web_server_logs module."""
from __future__ import annotations
from collections.abc import Iterator
import logging
import socket
from typing import Self
from unittest.mock import MagicMock
import pytest
import requests
from requests.auth import HTTPBasicAuth
from esphome import web_server_logs
from esphome.core import EsphomeError
from esphome.web_server_logs import (
EVENTS_PATH,
WebServerLogsError,
_build_urls,
_consume,
_stream,
run_logs,
)
# A realistic slice of the web_server /events SSE stream: an initial ping
# carrying the config, a state frame, two log frames (one multi-line), plus
# comment/id/retry lines that must be ignored.
SSE_LINES = [
"retry: 30000",
"id: 12345",
"event: ping",
'data: {"title":"dev","log":true}',
"",
"event: state",
'data: {"id":"sensor-x","state":"ON"}',
"",
"event: log",
"data: \x1b[0;32m[I][main:001]: hello\x1b[0m",
"",
": keepalive-comment",
"event: log",
"data: line one",
"data: line two",
"",
]
class _FakeResponse:
"""Minimal stand-in for a streamed ``requests`` response."""
def __init__(self, status_code: int, lines: list[str]) -> None:
self.status_code = status_code
self._lines = lines
def __enter__(self) -> Self:
return self
def __exit__(self, *exc: object) -> bool:
return False
def iter_lines(self) -> Iterator[bytes]:
for line in self._lines:
yield line.encode("utf8")
@pytest.fixture
def fake_parser() -> MagicMock:
"""A LogParser whose parse_line returns the raw line unchanged."""
parser = MagicMock()
parser.parse_line.side_effect = lambda line, time_str: line
return parser
def _patch_resolve(
monkeypatch: pytest.MonkeyPatch,
addr_infos: list[tuple[int, int, int, str, tuple]],
) -> None:
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address",
lambda *args, **kwargs: addr_infos,
)
# ---------------------------------------------------------------------------
# _build_urls
# ---------------------------------------------------------------------------
def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
"""An IPv4 host resolves to a plain http://ip:port/events URL."""
_patch_resolve(
monkeypatch,
[(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))],
)
assert _build_urls(["dev.local"], 80) == [
("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}")
]
def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None:
"""IPv6 literals are bracketed; link-local addresses get a %25 zone index."""
_patch_resolve(
monkeypatch,
[(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))],
)
assert _build_urls(["dev.local"], 8080) == [
("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}")
]
def test_build_urls_dedups_and_skips_unresolvable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Duplicate resolved IPs collapse to one URL; resolve errors are skipped."""
calls: list[str] = []
def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]:
calls.append(host)
if host == "bad":
raise EsphomeError("nope")
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))]
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve)
# "good" and "dup" both resolve to 10.0.0.1, "bad" raises.
assert _build_urls(["good", "bad", "dup"], 80) == [
("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}")
]
assert calls == ["good", "bad", "dup"]
# ---------------------------------------------------------------------------
# _consume (SSE parsing)
# ---------------------------------------------------------------------------
def test_consume_emits_only_log_frames(
monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock
) -> None:
"""Only event: log data lines are printed; ping/state/comments are ignored."""
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
_consume(_FakeResponse(200, SSE_LINES), fake_parser)
assert printed == [
"\x1b[0;32m[I][main:001]: hello\x1b[0m",
"line one",
"line two",
]
def test_consume_ignores_unterminated_trailing_frame(
monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock
) -> None:
"""A log frame without its terminating blank line is not emitted."""
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
_consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser)
assert printed == []
# ---------------------------------------------------------------------------
# _stream
# ---------------------------------------------------------------------------
def test_stream_returns_false_when_connect_fails(
monkeypatch: pytest.MonkeyPatch,
fake_parser: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failed connection logs a warning and reports not-connected."""
def boom(*args: object, **kwargs: object) -> _FakeResponse:
raise requests.ConnectionError("refused")
monkeypatch.setattr(requests, "get", boom)
with caplog.at_level(logging.WARNING):
assert (
_stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False
)
assert "Could not connect to 10.0.0.1" in caplog.text
def test_stream_returns_true_when_established_then_dropped(
monkeypatch: pytest.MonkeyPatch,
fake_parser: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A mid-stream drop after connecting reports connected so we reconnect."""
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
class _DroppingResponse(_FakeResponse):
def iter_lines(self) -> Iterator[bytes]:
yield b"event: log"
yield b"data: before-drop"
yield b""
raise requests.exceptions.ChunkedEncodingError("connection lost")
monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, []))
with caplog.at_level(logging.INFO):
assert (
_stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True
)
assert printed == ["before-drop"]
assert "reconnecting" in caplog.text
# ---------------------------------------------------------------------------
# run_logs
# ---------------------------------------------------------------------------
def test_run_logs_streams_then_reconnects_until_interrupt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A dropped stream reconnects; KeyboardInterrupt during the pause exits 0."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
printed: list[str] = []
monkeypatch.setattr(web_server_logs, "safe_print", printed.append)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES))
def stop(_delay: float) -> None:
raise KeyboardInterrupt
monkeypatch.setattr(web_server_logs.time, "sleep", stop)
assert run_logs(["dev.local"], 80, None, None) == 0
# The single stream was consumed before the reconnect pause interrupted us.
# run_logs renders through the real LogParser, which prefixes a timestamp,
# so assert on the payloads rather than exact equality.
assert len(printed) == 3
assert "[I][main:001]: hello" in printed[0]
assert "line one" in printed[1]
assert "line two" in printed[2]
def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None:
"""Username + password are forwarded as HTTP Basic auth on the request."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None)
captured: dict[str, object] = {}
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
captured.update(kwargs)
captured["url"] = url
return _FakeResponse(200, SSE_LINES)
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(
web_server_logs.time,
"sleep",
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
)
assert run_logs(["dev.local"], 80, "admin", "secret") == 0
auth = captured["auth"]
assert isinstance(auth, HTTPBasicAuth)
assert (auth.username, auth.password) == ("admin", "secret")
assert captured["stream"] is True
assert captured["headers"] == {"Accept": "text/event-stream"}
def test_run_logs_no_auth_when_credentials_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No auth object is sent when username/password are not configured."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None)
captured: dict[str, object] = {}
def fake_get(url: str, **kwargs: object) -> _FakeResponse:
captured.update(kwargs)
return _FakeResponse(200, SSE_LINES)
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(
web_server_logs.time,
"sleep",
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
)
assert run_logs(["dev.local"], 80, None, None) == 0
assert captured["auth"] is None
def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""HTTP 401 aborts with a clear error rather than reconnecting forever."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, []))
with pytest.raises(WebServerLogsError, match="Authentication failed"):
run_logs(["dev.local"], 80, "admin", "bad")
def test_run_logs_retries_on_transient_status(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A transient non-200 (e.g. 503) is logged and the loop retries."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, []))
monkeypatch.setattr(
web_server_logs.time,
"sleep",
lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()),
)
with caplog.at_level(logging.ERROR):
assert run_logs(["dev.local"], 80, None, None) == 0
assert "Unexpected HTTP 503" in caplog.text
@pytest.mark.parametrize("status", (403, 404))
def test_run_logs_raises_on_permanent_status(
monkeypatch: pytest.MonkeyPatch, status: int
) -> None:
"""A permanent 403/404 aborts instead of retrying the endpoint forever."""
monkeypatch.setattr(
web_server_logs,
"_build_urls",
lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")],
)
monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, []))
with pytest.raises(WebServerLogsError, match=str(status)):
run_logs(["dev.local"], 80, None, None)
def test_run_logs_backs_off_on_repeated_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Consecutive unreachable attempts grow the reconnect delay up to the cap."""
monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: [])
delays: list[float] = []
def record(delay: float) -> None:
delays.append(delay)
if len(delays) >= 4:
raise KeyboardInterrupt
monkeypatch.setattr(web_server_logs.time, "sleep", record)
assert run_logs(["dev.local"], 80, None, None) == 0
# 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0).
assert delays == [2.0, 4.0, 8.0, 10.0]
def test_run_logs_reports_unresolvable(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""When no host resolves, an error is logged and the loop pauses/retries."""
monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: [])
# Let the first reconnect pause pass so the loop continues, then interrupt
# on the second so the retry path (the ``continue``) is exercised.
sleeps = {"n": 0}
def sleep(_delay: float) -> None:
sleeps["n"] += 1
if sleeps["n"] >= 2:
raise KeyboardInterrupt
monkeypatch.setattr(web_server_logs.time, "sleep", sleep)
with caplog.at_level(logging.ERROR):
assert run_logs(["dev.local"], 80, None, None) == 0
assert sleeps["n"] == 2
assert "Could not resolve" in caplog.text
+7 -7
View File
@@ -46,7 +46,7 @@ def _patch_resolve(
for host, port in hosts
]
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
)
@@ -475,7 +475,7 @@ def test_run_ota_resolution_failure(
def _raise(*_args, **_kwargs):
raise EsphomeError("dns failed")
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise)
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
@@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode(
def _raise(*_args, **_kwargs):
raise EsphomeError("dns failed")
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise)
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise)
monkeypatch.setattr(CORE, "dashboard", True)
try:
exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware)
@@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails(
def _resolve(host, port, address_cache=None): # noqa: ARG001
return addr_lookup[host]
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve)
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
with patch(
"esphome.web_server_ota.requests.post",
@@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception(
def _resolve(host, port, address_cache=None): # noqa: ARG001
return addr_lookup[host]
monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve)
monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve)
exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware)
@@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host(
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)),
]
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
)
with patch(
@@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id(
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)),
]
monkeypatch.setattr(
"esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos
"esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos
)
with patch(
+1 -4
View File
@@ -543,8 +543,6 @@ def test_clean_build(
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.return_value = dependencies_lock
mock_core.platformio_cache_dir = str(platformio_cache_dir)
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.name = "test"
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
@@ -927,8 +925,7 @@ def test_write_cpp_creates_new_file(
assert CPP_AUTO_GENERATE_END in written_content
assert test_code in written_content
assert "void setup()" in written_content
assert 'optimize("O2")' in written_content
assert "loop()" in written_content
assert "void loop()" in written_content
assert "App.setup();" in written_content
assert "App.loop();" in written_content