mirror of
https://github.com/esphome/esphome.git
synced 2026-07-08 16:05:34 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69c6b3e38d | |||
| b4ad0eb86b | |||
| 7f0e826c32 | |||
| 76ee3fe887 | |||
| af4a6e7ec3 | |||
| 40c3a4320f | |||
| 3c2dad67f4 | |||
| f823a23ea4 | |||
| 9aed1d2700 | |||
| 9857d508d9 | |||
| ad7c980c4b |
+17
-45
@@ -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)")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
# pylint: disable=unused-import
|
||||
from esphome.cpp_generator import ( # noqa: F401
|
||||
ArrayInitializer,
|
||||
ComponentMarker,
|
||||
Expression,
|
||||
FlashStringLiteral,
|
||||
LineComment,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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_{};
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+2
-2
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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({})
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user