Compare commits

...

11 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:22:49 +00:00
J. Nick Koston b4ad0eb86b [esp32_ble] Fix boot loop when the hosted co-processor does not answer BT bring-up (#17429) 2026-07-07 14:59:18 +00:00
Clyde Stubbs 7f0e826c32 [lvgl] Add paused option to suppress updates on boot (#16973)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-07 14:50:36 +00:00
Remco van Essen 76ee3fe887 [audio_file] Accept mp1/mp2 puremagic detections as MP3 (#17436)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 08:56:48 -04:00
Oliver Kleinecke af4a6e7ec3 [usb_uart] Fix FTDI RX data stall / corruption and input restart reliability (#17348)
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:55:49 +10:00
luar123 40c3a4320f [core] add const for litre per hour (#17389)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 21:44:44 +10:00
J. Nick Koston 3c2dad67f4 [network] Fix logged use_address with MAC suffix and build it at runtime (#17432) 2026-07-07 02:07:34 -05:00
Remco van Essen f823a23ea4 [pcm5122] Add analog gain, channel mixing, volume range, standby/powerdown switch, and XSMT enable pin support (#17313) 2026-07-07 00:15:37 -05:00
Keith Burzinski 9aed1d2700 [esp32] Add NVS encryption (HMAC scheme) (#17004) 2026-07-07 16:04:38 +12:00
Clyde Stubbs 9857d508d9 [light] Preserve brightness on turn-off. (#17103)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-07 15:57:06 +12:00
Clyde Stubbs ad7c980c4b [lvgl] Continue activity while display busy (#17374) 2026-07-07 03:16:29 +00:00
46 changed files with 862 additions and 116 deletions
+2 -1
View File
@@ -240,12 +240,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
}
void APIServer::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Server:\n"
" Address: %s:%u\n"
" Listen backlog: %u\n"
" Max connections: %u",
network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
#ifdef USE_API_NOISE
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
if (!this->noise_ctx_.has_psk()) {
+3 -1
View File
@@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type == "wav":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
elif file_type in ("mp3", "mpeg", "mpga"):
elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"):
# With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2".
# Treat those labels as MP3 so we still pick the MP3 decoder.
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
elif file_type == "flac":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
+62
View File
@@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_KEY_ID = "key_id"
CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision"
CONF_NVS_ENCRYPTION = "nvs_encryption"
CONF_RELEASE = "release"
CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification"
CONF_SIGNING_KEY = "signing_key"
@@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = {
VARIANT_ESP32,
}
# NVS encryption (HMAC peripheral scheme) is only available on variants that
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
# should be added here.
NVS_ENCRYPTION_HMAC_VARIANTS = {
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32H2,
VARIANT_ESP32P4,
}
COMPILER_OPTIMIZATIONS = {
"DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG",
"NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE",
@@ -1349,6 +1365,29 @@ def final_validate(config):
"Binaries will NOT be signed automatically during build. "
"You must sign them externally before flashing."
)
if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None:
variant = config[CONF_VARIANT]
if variant in NVS_ENCRYPTION_HMAC_VARIANTS:
_LOGGER.warning(
"NVS encryption will burn an HMAC key into eFuse key block %d on the "
"first boot of each device. This is PERMANENT and IRREVERSIBLE: "
"the block cannot be erased or reused afterwards. Enabling (or "
"later disabling) encryption also wipes any previously saved "
"preferences once, because the older data can no longer be read.",
nvs_enc[CONF_KEY_ID],
)
else:
supported = ", ".join(
sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS)
)
errs.append(
cv.Invalid(
f"NVS encryption (HMAC scheme) is not supported on "
f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). "
f"Supported variants: {supported}.",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION],
)
)
if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]:
project = full_config[CONF_ESPHOME].get(CONF_PROJECT)
errs.extend(
@@ -1609,6 +1648,15 @@ FRAMEWORK_SCHEMA = cv.Schema(
),
cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
),
cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema(
{
# eFuse key block (0-5) that stores the HMAC key from
# which the NVS encryption keys are derived. The block is
# written on first boot if empty -- an irreversible
# operation -- so it must be chosen explicitly.
cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5),
}
),
cv.Optional(
CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False
): cv.boolean,
@@ -2451,6 +2499,20 @@ async def to_code(config):
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
# Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are
# derived at runtime from an HMAC key stored in the configured eFuse block
# (no flash encryption required). The HMAC key is generated and burned into
# the eFuse block on first boot if it is empty. With the scheme selected,
# nvs_sec_provider registers it at startup and the default nvs_flash_init()
# (used in esp32/preferences.cpp) transparently performs the secure init, so
# no C++ changes are needed.
if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None:
add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True)
add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True)
add_idf_sdkconfig_option(
"CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID]
)
cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE])
cg.add_define(
+47 -5
View File
@@ -9,6 +9,8 @@
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#include <esp_bt.h>
#else
#include "esphome/components/watchdog/watchdog.h"
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#include <esp_hosted_misc.h>
@@ -33,6 +35,19 @@ namespace esphome::esp32_ble {
static const char *const TAG = "esp32_ble";
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
// Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog
// is also 5 seconds. If the co-processor firmware does not answer (for example
// factory firmware without Bluetooth support), the watchdog would reboot the
// device before the RPC could return an error, causing a boot loop. Raise the
// watchdog for the duration of the bring-up so failures surface as error
// returns instead. 60 seconds covers the worst case: transport reconnect
// (up to ~20s), version preflight (1s), controller init/enable (5s each) and
// the bluedroid host bring-up over the hosted HCI transport.
static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
#endif
// GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_
#define GAP_SCAN_COMPLETE_EVENTS \
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \
@@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() {
bool ESP32BLE::ble_setup_() {
esp_err_t err;
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) {
// start bt controller
@@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() {
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
#else
esp_hosted_connect_to_slave(); // NOLINT
if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT
ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled");
return false;
}
// Fast preflight (1 second RPC timeout): verifies the co-processor answers
// RPCs at all before the 5 second timeout BT controller RPCs below, and
// before hosted_hci_bluedroid_open(), which aborts if the transport is down.
esp_hosted_coprocessor_fwver_t fw_ver{};
if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) {
ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted "
"update component");
return false;
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
if (esp_hosted_bt_controller_init() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed");
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
if (esp_hosted_bt_controller_enable() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed");
ESP_LOGE(TAG,
"BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
@@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() {
}
bool ESP32BLE::ble_dismantle_() {
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
// Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
esp_err_t err = esp_bluedroid_disable();
if (err != ESP_OK) {
// ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine
@@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() {
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed");
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
return false;
}
if (esp_hosted_bt_controller_deinit(false) != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed");
ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed");
return false;
}
@@ -18,6 +18,8 @@ from esphome.const import (
from esphome.cpp_generator import add_define
CODEOWNERS = ["@swoboda1337"]
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
@@ -94,11 +94,12 @@ void ESPHomeOTAComponent::setup() {
}
void ESPHomeOTAComponent::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Over-The-Air updates:\n"
" Address: %s:%u\n"
" Version: %d",
network::get_use_address(), this->port_, USE_OTA_VERSION);
network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION);
#ifdef USE_OTA_PASSWORD
if (!this->password_.empty()) {
ESP_LOGCONFIG(TAG, " Password configured");
+2 -2
View File
@@ -4,7 +4,7 @@ import logging
from esphome import automation, pins
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.network import ip_address_literal
from esphome.components.network import add_use_address, ip_address_literal
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -543,7 +543,7 @@ async def to_code(config):
await _to_code_rp2040(var, config)
cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]]))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
add_use_address(var, config[CONF_USE_ADDRESS])
# enable_on_boot defaults to true in C++ - only set if false
if not config[CONF_ENABLE_ON_BOOT]:
cg.add(var.set_enable_on_boot(False))
@@ -145,6 +145,8 @@ class EthernetComponent final : public Component {
network::IPAddresses get_ip_addresses();
network::IPAddress get_dns_address(uint8_t num);
/// Returns nullptr when no explicit use_address is configured and the address is
/// derived at runtime from the device name (see network::get_use_address_to()).
const char *get_use_address() const { return this->use_address_; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
void get_eth_mac_address_raw(uint8_t *mac);
@@ -346,7 +348,7 @@ class EthernetComponent final : public Component {
private:
// Stores a pointer to a string literal (static storage duration).
// ONLY set from Python-generated code with string literals - never dynamic strings.
const char *use_address_{""};
const char *use_address_{nullptr};
};
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
+1 -1
View File
@@ -21,6 +21,7 @@ from esphome.const import (
UNIT_EMPTY,
UNIT_KELVIN,
UNIT_KILOWATT,
UNIT_LITRE_PER_HOUR,
)
CODEOWNERS = ["@cfeenstra1024"]
@@ -37,7 +38,6 @@ CONF_TEMP2 = "temp2"
CONF_TEMP_DIFF = "temp_diff"
UNIT_GIGA_JOULE = "GJ"
UNIT_LITRE_PER_HOUR = "l/h"
# Note: The sensor units are set automatically based un the received data from the meter
CONFIG_SCHEMA = (
+10 -8
View File
@@ -213,17 +213,19 @@ LightColorValues LightCall::validate_() {
// Flag whether an explicit turn off was requested, in which case we'll also stop the effect.
bool explicit_turn_off_request = this->has_state() && !this->state_;
// Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on).
if (this->has_brightness() && this->brightness_ == 0.0f) {
// Treat zero brightness as an implicit turn-off when no state was explicitly requested.
if (this->has_brightness() && this->brightness_ == 0.0f && !this->has_state()) {
this->state_ = false;
this->set_flag_(FLAG_HAS_STATE);
if (color_mode & ColorCapability::BRIGHTNESS) {
// Reset brightness so the light has nonzero brightness when turned back on.
}
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
if (brightness == 0.0f) {
this->brightness_ = 1.0f;
} else {
// Light doesn't support brightness; clear the flag to avoid a spurious
// "brightness not supported" warning during capability validation.
this->clear_flag_(FLAG_HAS_BRIGHTNESS);
this->set_flag_(FLAG_HAS_BRIGHTNESS);
}
}
+6
View File
@@ -414,6 +414,10 @@ async def to_code(configs):
await cg.register_component(lv_component, config)
if rotation := config.get(CONF_ROTATION):
cg.add(lv_component.set_rotation(rotation))
if paused := config[df.CONF_PAUSED]:
cg.add(lv_component.set_paused(paused, False))
if refr_time := config.get(df.CONF_REFRESH_INTERVAL):
cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds))
Widget.create(config[CONF_ID], lv_component, LvScrActType(), config)
lv_scr_act = get_screen_active(lv_component)
@@ -598,6 +602,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
cv.Optional(df.CONF_REFRESH_INTERVAL): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_ROTATION): validate_rotation,
@@ -642,6 +647,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
cv.Optional(df.CONF_PAUSED, default=False): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA)
+2
View File
@@ -758,12 +758,14 @@ CONF_PAD_COLUMN = "pad_column"
CONF_PAGE = "page"
CONF_PAGE_WRAP = "page_wrap"
CONF_PASSWORD_MODE = "password_mode"
CONF_PAUSED = "paused"
CONF_PIVOT_X = "pivot_x"
CONF_PIVOT_Y = "pivot_y"
CONF_PLACEHOLDER_TEXT = "placeholder_text"
CONF_POINTS = "points"
CONF_PREVIOUS = "previous"
CONF_RADIUS = "radius"
CONF_REFRESH_INTERVAL = "refresh_interval"
CONF_REPEAT_COUNT = "repeat_count"
CONF_RECOLOR = "recolor"
CONF_RESUME_ON_INPUT = "resume_on_input"
+38 -22
View File
@@ -401,7 +401,10 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) {
}
void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) {
if (!this->is_paused()) {
// no guard here for display busy, since LVGL will not call flush_cb until the refresh timer fires,
// and while the display is busy this is reset to 5 minutes. If that expires and the display is still
// busy there are bigger problems.
if (!this->paused_) {
auto now = millis();
this->draw_buffer_(area, reinterpret_cast<lv_color_data *>(color_p));
ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", (int) area->x1, (int) area->y1,
@@ -620,20 +623,20 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) {
void LvglComponent::draw_end_() {
if (this->draw_end_callback_ != nullptr)
this->draw_end_callback_->trigger();
// Only reachable once the display is idle again: while busy, the display's refr_timer_ is
// paused (see loop()), so LVGL never renders/flushes and this event never fires.
if (this->update_when_display_idle_) {
for (auto *disp : this->displays_)
disp->update();
}
}
bool LvglComponent::is_paused() const {
if (this->paused_)
return true;
if (this->update_when_display_idle_) {
for (auto *disp : this->displays_) {
if (!disp->is_idle())
return true;
}
bool LvglComponent::displays_busy_() const {
if (!this->update_when_display_idle_)
return false;
for (auto *disp : this->displays_) {
if (!disp->is_idle())
return true;
}
return false;
}
@@ -777,6 +780,8 @@ void LvglComponent::setup() {
if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) {
lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this);
}
this->refr_timer_ = lv_display_get_refr_timer(this->disp_);
lv_timer_set_period(this->refr_timer_, this->refr_timer_period_);
#if LV_USE_LOG
lv_log_register_print_cb([](lv_log_level_t level, const char *buf) {
auto next = strchr(buf, ')');
@@ -802,21 +807,32 @@ void LvglComponent::update() {
}
void LvglComponent::loop() {
if (this->is_paused()) {
if (this->paused_ && this->show_snow_)
if (this->paused_) {
if (this->show_snow_)
this->write_random_();
} else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
auto now = millis();
lv_timer_handler();
auto elapsed = millis() - now;
if (elapsed > 15) {
ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now));
}
#else
lv_timer_handler();
#endif
return;
}
// Pause/resume the display's own refresh timer to track its busy state. While paused, LVGL
// still keeps track of invalidated areas but won't render or flush them, so nothing needs to
// be discarded or replayed: once resumed, the accumulated areas are simply drawn as normal.
// Input events and other timers keep being processed below regardless of this state.
if (this->update_when_display_idle_) {
bool busy = this->displays_busy_();
if (busy && !this->refr_timer_paused_) {
this->refr_timer_paused_ = true;
// calling lv_timer_pause() here would be ineffective; LVGL pauses and resumes the timer based on its own internal
// state, which is not aware of the display's busy state. Instead, we extend the timer period to avoid it firing
// while the display is busy.
lv_timer_set_period(this->refr_timer_, 5 * 60 * 1000);
} else if (!busy && this->refr_timer_paused_) {
this->refr_timer_paused_ = false;
lv_timer_set_period(this->refr_timer_, this->refr_timer_period_);
// Don't wait for the timer's next natural period: refresh right away now that the
// display is idle again.
lv_timer_ready(this->refr_timer_);
}
}
lv_timer_handler();
}
#ifdef USE_LVGL_ANIMIMG
+18 -2
View File
@@ -214,9 +214,14 @@ class LvglComponent final : public PollingComponent {
// @param paused If true, pause the display. If false, resume the display.
// @param show_snow If true, show the snow effect when paused.
void set_paused(bool paused, bool show_snow);
void set_refresh_interval(uint32_t period) {
this->refr_timer_period_ = period;
if (this->refr_timer_ != nullptr)
lv_timer_set_period(this->refr_timer_, period);
}
// Returns true if the display is explicitly paused, or a blocking display update is in progress.
bool is_paused() const;
// Returns true if the display has been explicitly paused via set_paused().
bool is_paused() const { return this->paused_; }
// If the display is paused and we have resume_on_input_ set to true, resume the display.
void maybe_wakeup() {
if (this->paused_ && this->resume_on_input_) {
@@ -299,6 +304,9 @@ class LvglComponent final : public PollingComponent {
// Not checking for non-null callback since the
// LVGL callback that calls it is not set in that case
void draw_start_() const { this->draw_start_callback_->trigger(); }
// Returns true if update_when_display_idle is enabled and at least one underlying display
// component is currently busy (e.g. mid-refresh).
bool displays_busy_() const;
void write_random_();
void draw_buffer_(const lv_area_t *area, lv_color_data *ptr);
@@ -316,6 +324,14 @@ class LvglComponent final : public PollingComponent {
uint8_t *draw_buf_{};
lv_display_t *disp_{};
// The display's own periodic refresh timer, effectively paused while the display is busy (see
// displays_busy_()) so LVGL neither renders nor flushes to it, without losing track of
// invalidated areas. Other timers (indev reading, animations, ...) keep running as normal.
lv_timer_t *refr_timer_{};
// Tracks whether refr_timer_ is currently paused, so loop() can detect the busy -> idle edge
// and kick off an immediate refresh instead of waiting for the timer's next natural period.
bool refr_timer_paused_{};
uint32_t refr_timer_period_{16};
uint16_t width_{};
uint16_t height_{};
bool paused_{};
+13
View File
@@ -59,6 +59,19 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj:
return IPAddress(str(ip))
def add_use_address(var: cg.MockObj, use_address: str) -> None:
"""Generate a set_use_address() call only when the address must be baked in.
The default "<name>.local" is not stored in the firmware; it is rebuilt at
runtime from the device name (see network::get_use_address_to()), which also
picks up the MAC suffix when name_add_mac_suffix is enabled. A compile-time
string could never include that suffix, so baking it in would log the wrong
address.
"""
if use_address != f"{CORE.name}.local":
cg.add(var.set_use_address(use_address))
def require_high_performance_networking() -> None:
"""Request high performance networking for network and WiFi.
+23
View File
@@ -1,5 +1,7 @@
#include "util.h"
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_NETWORK
namespace esphome::network {
@@ -20,6 +22,27 @@ bool is_disabled() {
return false;
}
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf) {
// Global component pointers are guaranteed to be set by component constructors when USE_* is defined
const char *addr = nullptr;
#if defined(USE_ETHERNET)
addr = ethernet::global_eth_component->get_use_address();
#elif defined(USE_MODEM)
addr = modem::global_modem_component->get_use_address();
#elif defined(USE_WIFI)
addr = wifi::global_wifi_component->get_use_address();
#elif defined(USE_OPENTHREAD)
addr = openthread::global_openthread_component->get_use_address();
#endif
if (addr != nullptr && addr[0] != '\0')
return addr;
// No explicit use_address configured: the address is the runtime device name
// (which includes the MAC suffix when name_add_mac_suffix is enabled) plus ".local"
const auto &name = App.get_name();
make_name_with_suffix_to(buf.data(), buf.size(), name.c_str(), name.size(), '.', "local", 5);
return buf.data();
}
network::IPAddresses get_ip_addresses() {
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr)
+7 -24
View File
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_NETWORK
#include <span>
#include <string>
#include "esphome/core/helpers.h"
#include "ip_address.h"
@@ -53,30 +54,12 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() {
/// Return whether the network is disabled (only wifi for now)
bool is_disabled();
/// Get the active network hostname
ESPHOME_ALWAYS_INLINE inline const char *get_use_address() {
// Global component pointers are guaranteed to be set by component constructors when USE_* is defined
#ifdef USE_ETHERNET
return ethernet::global_eth_component->get_use_address();
#endif
#ifdef USE_MODEM
return modem::global_modem_component->get_use_address();
#endif
#ifdef USE_WIFI
return wifi::global_wifi_component->get_use_address();
#endif
#ifdef USE_OPENTHREAD
return openthread::global_openthread_component->get_use_address();
#endif
#if !defined(USE_ETHERNET) && !defined(USE_MODEM) && !defined(USE_WIFI) && !defined(USE_OPENTHREAD)
// Fallback when no network component is defined (e.g., host platform)
return "";
#endif
}
/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator
static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70;
/// Get the active network address for logging. Returns the explicitly configured
/// use_address when one was set, otherwise formats "<name>.local" from the runtime
/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix).
const char *get_use_address_to(std::span<char, USE_ADDRESS_BUFFER_SIZE> buf);
IPAddresses get_ip_addresses();
} // namespace esphome::network
+2 -1
View File
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
require_vfs_select,
)
from esphome.components.mdns import MDNSComponent, enable_mdns_storage
from esphome.components.network import add_use_address
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -288,7 +289,7 @@ async def to_code(config):
enable_mdns_storage()
ot = cg.new_Pvariable(config[CONF_ID])
cg.add(ot.set_use_address(config[CONF_USE_ADDRESS]))
add_use_address(ot, config[CONF_USE_ADDRESS])
await cg.register_component(ot, config)
if (poll_period := config.get(CONF_POLL_PERIOD)) is not None:
cg.add(ot.set_poll_period(poll_period))
+3 -1
View File
@@ -39,6 +39,8 @@ class OpenThreadComponent final : public Component {
void on_factory_reset(std::function<void()> callback);
void defer_factory_reset_external_callback();
/// Returns nullptr when no explicit use_address is configured and the address is
/// derived at runtime from the device name (see network::get_use_address_to()).
const char *get_use_address() const { return this->use_address_; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
#if CONFIG_OPENTHREAD_MTD
@@ -76,7 +78,7 @@ class OpenThreadComponent final : public Component {
private:
// Stores a pointer to a string literal (static storage duration).
// ONLY set from Python-generated code with string literals - never dynamic strings.
const char *use_address_{""};
const char *use_address_{nullptr};
};
extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+49 -2
View File
@@ -5,6 +5,7 @@ from esphome.components.audio_dac import AudioDac
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_ENABLE_PIN,
CONF_ID,
CONF_INPUT,
CONF_INVERTED,
@@ -16,6 +17,11 @@ from esphome.const import (
CODEOWNERS = ["@remcom"]
DEPENDENCIES = ["i2c"]
CONF_ANALOG_GAIN = "analog_gain"
CONF_CHANNEL_MIX = "channel_mix"
CONF_VOLUME_MIN_DB = "volume_min_db"
CONF_VOLUME_MAX_DB = "volume_max_db"
pcm5122_ns = cg.esphome_ns.namespace("pcm5122")
PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice)
CONF_PCM5122 = "pcm5122"
@@ -27,26 +33,60 @@ PCM5122_BITS_PER_SAMPLE_ENUM = {
32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32,
}
pcm5122_analog_gain = pcm5122_ns.enum("PCM5122AnalogGain")
PCM5122_ANALOG_GAIN_ENUM = {
"0db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_0DB,
"-6db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_MINUS_6DB,
}
pcm5122_channel_mix = pcm5122_ns.enum("PCM5122ChannelMix")
PCM5122_CHANNEL_MIX_ENUM = {
"stereo": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_STEREO,
"left": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_LEFT_ONLY,
"right": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_RIGHT_ONLY,
"swapped": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_SWAPPED,
}
_validate_bits = cv.float_with_unit("bits", "bit")
def _validate_volume_range(config):
if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]:
raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}")
return config
PCM5122GPIOPin = pcm5122_ns.class_(
"PCM5122GPIOPin",
cg.GPIOPin,
cg.Parented.template(PCM5122),
)
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(PCM5122),
cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All(
_validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM)
),
cv.Optional(CONF_ANALOG_GAIN, default="0db"): cv.enum(
PCM5122_ANALOG_GAIN_ENUM, lower=True
),
cv.Optional(CONF_CHANNEL_MIX, default="stereo"): cv.enum(
PCM5122_CHANNEL_MIX_ENUM, lower=True
),
cv.Optional(CONF_VOLUME_MIN_DB, default="-52.5dB"): cv.All(
cv.decibel, cv.float_range(min=-103.0, max=24.0)
),
cv.Optional(CONF_VOLUME_MAX_DB, default="0dB"): cv.All(
cv.decibel, cv.float_range(min=-103.0, max=24.0)
),
cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x4D))
.extend(i2c.i2c_device_schema(0x4D)),
_validate_volume_range,
)
@@ -96,3 +136,10 @@ async def to_code(config):
await i2c.register_i2c_device(var, config)
cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN]))
cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX]))
cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB]))
cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB]))
if enable_pin_config := config.get(CONF_ENABLE_PIN):
enable_pin = await cg.gpio_pin_expression(enable_pin_config)
cg.add(var.set_enable_pin(enable_pin))
+99 -5
View File
@@ -10,6 +10,12 @@ namespace esphome::pcm5122 {
static const char *const TAG = "pcm5122";
void PCM5122::setup() {
// Hold XSMT low (soft mute asserted) until init completes
if (this->enable_pin_ != nullptr) {
this->enable_pin_->setup();
this->enable_pin_->digital_write(false);
}
// Select page 0 and verify chip presence via I2C ACK
if (!this->select_page_(0)) {
ESP_LOGE(TAG, "Write failed");
@@ -51,7 +57,22 @@ void PCM5122::setup() {
}
this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen;
if (!this->write_channel_mix_()) {
this->mark_failed();
return;
}
if (!this->write_analog_gain_()) {
this->mark_failed();
return;
}
// PLL reference clock: BCK
if (!this->select_page_(0)) {
ESP_LOGE(TAG, "Write failed");
this->mark_failed();
return;
}
optional<uint8_t> pll_ref = this->read_byte(PCM5122_REG_PLL_REF);
if (!pll_ref.has_value()) {
ESP_LOGE(TAG, "Failed to read PLL_REF");
@@ -67,15 +88,40 @@ void PCM5122::setup() {
this->mark_failed();
return;
}
// Release XSMT (soft un-mute) now that init has completed
if (this->enable_pin_ != nullptr) {
this->enable_pin_->digital_write(true);
}
}
void PCM5122::dump_config() {
const char *channel_mix_str;
switch (this->channel_mix_) {
case PCM5122_CHANNEL_MIX_LEFT_ONLY:
channel_mix_str = "left only";
break;
case PCM5122_CHANNEL_MIX_RIGHT_ONLY:
channel_mix_str = "right only";
break;
case PCM5122_CHANNEL_MIX_SWAPPED:
channel_mix_str = "swapped";
break;
default:
channel_mix_str = "stereo";
break;
}
ESP_LOGCONFIG(TAG, "Audio DAC:");
LOG_I2C_DEVICE(this);
ESP_LOGCONFIG(TAG,
" Bits per sample: %u\n"
" Analog gain: %s\n"
" Channel mix: %s\n"
" Volume range: %.1f dB to %.1f dB\n"
" Muted: %s",
this->bits_per_sample_, YESNO(this->is_muted_));
this->bits_per_sample_, this->analog_gain_ == PCM5122_ANALOG_GAIN_0DB ? "0 dB" : "-6 dB",
channel_mix_str, this->volume_min_db_, this->volume_max_db_, YESNO(this->is_muted_));
LOG_PIN(" Enable Pin: ", this->enable_pin_);
}
bool PCM5122::set_mute_off() {
@@ -118,11 +164,11 @@ bool PCM5122::write_mute_() {
}
bool PCM5122::write_volume_() {
// DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step).
// Note: volume=0.0 maps to -52.5 dB (still audible), not true silence.
// DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFE = -103 dB, 0xFF = mute (-0.5 dB/step).
// Note: volume=0.0 maps to volume_min_db_, which is not true silence unless set to -103 dB.
// Use set_mute_on() for silence.
const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale
const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum
const uint8_t dvol_max_volume = static_cast<uint8_t>(lroundf(0x30 - this->volume_max_db_ * 2.0f));
const uint8_t dvol_min_volume = static_cast<uint8_t>(lroundf(0x30 - this->volume_min_db_ * 2.0f));
const uint8_t volume_byte =
dvol_max_volume + static_cast<uint8_t>(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume)));
@@ -137,4 +183,52 @@ bool PCM5122::write_volume_() {
return true;
}
bool PCM5122::write_analog_gain_() {
uint8_t gain_byte = this->analog_gain_;
if (!this->select_page_(1) || !this->write_byte(PCM5122_REG_ANALOG_GAIN, gain_byte)) {
ESP_LOGE(TAG, "Writing analog gain failed");
return false;
}
return true;
}
bool PCM5122::write_channel_mix_() {
uint8_t channel_mix_byte = this->channel_mix_;
if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DAC_DATA_PATH, channel_mix_byte)) {
ESP_LOGE(TAG, "Writing channel mix failed");
return false;
}
return true;
}
bool PCM5122::set_standby(bool enable) {
bool prev_standby = this->standby_;
this->standby_ = enable;
if (!this->write_power_control_()) {
this->standby_ = prev_standby;
return false;
}
return true;
}
bool PCM5122::set_powerdown(bool enable) {
bool prev_powerdown = this->powerdown_;
this->powerdown_ = enable;
if (!this->write_power_control_()) {
this->powerdown_ = prev_powerdown;
return false;
}
return true;
}
bool PCM5122::write_power_control_() {
uint8_t power_byte =
(this->standby_ ? PCM5122_POWER_CONTROL_RQST : 0) | (this->powerdown_ ? PCM5122_POWER_CONTROL_RQPD : 0);
if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_POWER_CONTROL, power_byte)) {
ESP_LOGE(TAG, "Writing power control failed");
return false;
}
return true;
}
} // namespace esphome::pcm5122
+47 -2
View File
@@ -3,6 +3,7 @@
#include "esphome/components/audio_dac/audio_dac.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
namespace esphome::pcm5122 {
@@ -10,11 +11,13 @@ namespace esphome::pcm5122 {
// Page 0 register addresses
static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00;
static const uint8_t PCM5122_REG_RESET = 0x01;
static const uint8_t PCM5122_REG_POWER_CONTROL = 0x02;
static const uint8_t PCM5122_REG_MUTE = 0x03;
static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08;
static const uint8_t PCM5122_REG_PLL_REF = 0x0D;
static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25;
static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28;
static const uint8_t PCM5122_REG_DAC_DATA_PATH = 0x2A;
static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D;
static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E;
static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1
@@ -23,6 +26,9 @@ static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56;
static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57;
static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77;
// Page 1 register addresses
static const uint8_t PCM5122_REG_ANALOG_GAIN = 0x02;
// Register values for init sequence
static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules
static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00)
@@ -35,12 +41,33 @@ static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1);
static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4]
static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK)
// Page 0, Register 2 (Power Control): RQST = standby request, RQPD = powerdown request (§10.5.3)
static const uint8_t PCM5122_POWER_CONTROL_RQST = (1 << 4);
static const uint8_t PCM5122_POWER_CONTROL_RQPD = (1 << 0);
// Page 1, Register 2 (Analog Gain Control): LAGN/RAGN select 0 dB or -6 dB analog gain (§8.3.5.5)
static const uint8_t PCM5122_ANALOG_GAIN_LAGN = (1 << 4);
static const uint8_t PCM5122_ANALOG_GAIN_RAGN = (1 << 0);
enum PCM5122BitsPerSample : uint8_t {
PCM5122_BITS_PER_SAMPLE_16 = 16,
PCM5122_BITS_PER_SAMPLE_24 = 24,
PCM5122_BITS_PER_SAMPLE_32 = 32,
};
enum PCM5122AnalogGain : uint8_t {
PCM5122_ANALOG_GAIN_0DB = 0x00,
PCM5122_ANALOG_GAIN_MINUS_6DB = PCM5122_ANALOG_GAIN_LAGN | PCM5122_ANALOG_GAIN_RAGN,
};
// Page 0, Register 0x2A (DAC Data Path): AUPL/AUPR select which channel's data feeds each output (§7.4.2.42)
enum PCM5122ChannelMix : uint8_t {
PCM5122_CHANNEL_MIX_STEREO = 0x11, // Left data -> left out, right data -> right out
PCM5122_CHANNEL_MIX_LEFT_ONLY = 0x12, // Left data -> both outputs
PCM5122_CHANNEL_MIX_RIGHT_ONLY = 0x21, // Right data -> both outputs
PCM5122_CHANNEL_MIX_SWAPPED = 0x22, // Left/right outputs swapped
};
class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
public:
void setup() override;
@@ -48,6 +75,11 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::
float get_setup_priority() const override { return setup_priority::IO; }
void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; }
void set_analog_gain(PCM5122AnalogGain analog_gain) { this->analog_gain_ = analog_gain; }
void set_channel_mix(PCM5122ChannelMix channel_mix) { this->channel_mix_ = channel_mix; }
void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; }
void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; }
void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; }
bool set_mute_off() override;
bool set_mute_on() override;
@@ -56,17 +88,30 @@ class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::
bool is_muted() override;
float volume() override;
bool set_standby(bool enable);
bool set_powerdown(bool enable);
friend class PCM5122GPIOPin;
protected:
bool select_page_(uint8_t page);
bool write_mute_();
bool write_volume_();
bool write_analog_gain_();
bool write_channel_mix_();
bool write_power_control_();
float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB)
int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes
GPIOPin *enable_pin_{nullptr};
float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB)
float volume_min_db_{-52.5f}; // Matches the previous hardcoded minimum (0x99)
float volume_max_db_{0.0f}; // Matches the previous hardcoded maximum (0x30)
int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes
bool is_muted_{false};
bool standby_{false};
bool powerdown_{false};
PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16};
PCM5122AnalogGain analog_gain_{PCM5122_ANALOG_GAIN_0DB};
PCM5122ChannelMix channel_mix_{PCM5122_CHANNEL_MIX_STEREO};
};
} // namespace esphome::pcm5122
@@ -0,0 +1,32 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG
from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns
PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch)
pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode")
PCM5122_POWER_SWITCH_MODE_ENUM = {
"standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY,
"powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN,
}
CONFIG_SCHEMA = switch.switch_schema(
PCM5122PowerSwitch,
entity_category=ENTITY_CATEGORY_CONFIG,
).extend(
{
cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122),
cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum(
PCM5122_POWER_SWITCH_MODE_ENUM, lower=True
),
}
)
async def to_code(config):
var = await switch.new_switch(config)
await cg.register_parented(var, config[CONF_PCM5122])
cg.add(var.set_power_mode(config[CONF_POWER_MODE]))
@@ -0,0 +1,12 @@
#include "power_switch.h"
namespace esphome::pcm5122 {
void PCM5122PowerSwitch::write_state(bool state) {
bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state)
: this->parent_->set_powerdown(state);
if (ok)
this->publish_state(state);
}
} // namespace esphome::pcm5122
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/components/switch/switch.h"
#include "../pcm5122.h"
namespace esphome::pcm5122 {
enum PCM5122PowerSwitchMode : uint8_t {
PCM5122_POWER_SWITCH_MODE_STANDBY,
PCM5122_POWER_SWITCH_MODE_POWERDOWN,
};
class PCM5122PowerSwitch final : public switch_::Switch, public Parented<PCM5122> {
public:
void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; }
protected:
void write_state(bool state) override;
PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN};
};
} // namespace esphome::pcm5122
+38 -14
View File
@@ -3,6 +3,7 @@
#include "usb_uart.h"
#include "usb/usb_host.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/components/uart/uart_debugger.h"
#include "esphome/components/bytebuffer/bytebuffer.h"
@@ -396,7 +397,14 @@ int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) {
}
void USBUartTypeFT23XX::start_input(USBUartChannel *channel) {
if (!channel->initialised_.load() || channel->input_started_.load())
if (!channel->initialised_.load())
return;
// Use compare_exchange_strong to avoid a check-then-act race: start_input() is called
// from both the USB task (self-restart on success) and the main loop (backpressure
// restart), so a plain load()/store() pair can let both threads submit a transfer.
auto started = false;
if (!channel->input_started_.compare_exchange_strong(started, true))
return;
const auto *ep = channel->cdc_dev_.in_ep;
@@ -408,39 +416,55 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) {
return;
}
// FTDI prepends a 2-byte modem/line status header to every bulk IN packet.
size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0;
if (uart_data_len > 0) {
ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_);
if (!channel->dummy_receiver_) {
// Copy the entire received UART payload into the ring buffer in one
// operation to avoid per-byte overhead and reduce the chance of
// heap activity in hot paths.
channel->input_buffer_.push(status.data + 2, uart_data_len);
UsbDataChunk *chunk = this->chunk_pool_.allocate();
if (chunk == nullptr) {
this->usb_data_queue_.increment_dropped_count();
channel->input_started_.store(false);
// Queue is full — wake the main loop to drain it, then let read_array()
// retrigger start_input() rather than spinning here in the USB task.
this->enable_loop_soon_any_context();
App.wake_loop_threadsafe();
return;
}
// Strip the 2-byte FTDI header before queuing.
memcpy(chunk->data, status.data + 2, uart_data_len);
chunk->length = static_cast<uint16_t>(uart_data_len);
chunk->channel = channel;
this->usb_data_queue_.push(chunk);
#ifdef USE_UART_DEBUGGER
if (channel->debug_) {
// Debug path creates a temporary vector for logging only; this is
// acceptable because debug mode is opt-in and not used in release.
uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX,
std::vector<uint8_t>(status.data + 2, status.data + 2 + uart_data_len), ',',
channel->debug_prefix_);
}
#endif
this->enable_loop_soon_any_context();
App.wake_loop_threadsafe();
}
} else {
} else if (status.data_len >= 2) {
ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1],
channel->index_);
}
channel->input_started_.store(false);
if (channel->dummy_receiver_ ||
channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) {
this->start_input(channel);
}
this->start_input(channel);
};
channel->input_started_.store(true);
this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize);
if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) {
ESP_LOGE(TAG, "RX transfer submission failed for ep=0x%02X", ep->bEndpointAddress);
channel->input_started_.store(false);
}
}
void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) {
ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_);
channel->input_buffer_.clear();
}
void USBUartTypeFT23XX::enable_channels() {
+5
View File
@@ -228,6 +228,11 @@ void USBUartComponent::loop() {
}
#endif
// If there is not enough space for the full chunk, let the device subclass
// handle it (e.g. FTDI clears the buffer to prevent mid-telegram corruption).
if (channel->input_buffer_.get_free_space() < chunk->length) {
this->on_rx_overflow(channel);
}
// Push data to ring buffer (now safe in main loop)
channel->input_buffer_.push(chunk->data, chunk->length);
+7 -2
View File
@@ -192,9 +192,13 @@ class USBUartComponent : public usb_host::USBClient {
void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); }
void start_input(USBUartChannel *channel);
virtual void start_input(USBUartChannel *channel);
void start_output(USBUartChannel *channel);
// Called from loop() when input_buffer_ has insufficient space for the incoming chunk.
// Default is a no-op; override in device-specific subclasses that need resync on overflow.
virtual void on_rx_overflow(USBUartChannel *channel) {}
// Lock-free data transfer from USB task to main loop
static constexpr int USB_DATA_QUEUE_SIZE = 32;
LockFreeQueue<UsbDataChunk, USB_DATA_QUEUE_SIZE> usb_data_queue_;
@@ -248,7 +252,8 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm {
public:
USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {}
void start_input(USBUartChannel *channel);
void start_input(USBUartChannel *channel) override;
void on_rx_overflow(USBUartChannel *channel) override;
protected:
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
+2 -1
View File
@@ -421,10 +421,11 @@ void WebServer::on_log(uint8_t level, const char *tag, const char *message, size
#endif
void WebServer::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Web Server:\n"
" Address: %s:%u",
network::get_use_address(), this->base_->get_port());
network::get_use_address_to(addr_buf), this->base_->get_port());
}
float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f; }
+2 -1
View File
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
request_wifi,
)
from esphome.components.network import (
add_use_address,
has_high_performance_networking,
ip_address_literal,
)
@@ -585,7 +586,7 @@ def wifi_network(config, ap, static_ip):
@coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
add_use_address(var, config[CONF_USE_ADDRESS])
# Track if any network uses Enterprise authentication
has_eap = False
+3 -1
View File
@@ -501,6 +501,8 @@ class WiFiComponent final : public Component {
network::IPAddress get_dns_address(int num);
network::IPAddresses get_ip_addresses();
/// Returns nullptr when no explicit use_address is configured and the address is
/// derived at runtime from the device name (see network::get_use_address_to()).
const char *get_use_address() const { return this->use_address_; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
@@ -996,7 +998,7 @@ class WiFiComponent final : public Component {
private:
// Stores a pointer to a string literal (static storage duration).
// ONLY set from Python-generated code with string literals - never dynamic strings.
const char *use_address_{""};
const char *use_address_{nullptr};
};
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+1
View File
@@ -1255,6 +1255,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh"
UNIT_KILOWATT = "kW"
UNIT_KILOWATT_HOURS = "kWh"
UNIT_LITRE = "L"
UNIT_LITRE_PER_HOUR = "L/h"
UNIT_LITRE_PER_SECOND = "L/s"
UNIT_LUX = "lx"
UNIT_MEGAJOULE = "MJ"
+1 -1
View File
@@ -20,7 +20,7 @@ resvg-py==0.3.3
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
smpclient==7.2.0
smpclient==7.3.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.10.0 # native esp-idf toolchain global cache dir
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
+38
View File
@@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf(
r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]",
id="ignore_efuse_mac_crc_only_on_esp32",
),
pytest.param(
{
"variant": "esp32",
"board": "esp32dev",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_encryption": {"key_id": 0}},
},
},
r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]",
id="nvs_encryption_unsupported_on_esp32",
),
pytest.param(
{
"variant": "esp32s3",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_encryption": {"key_id": 6}},
},
},
r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]",
id="nvs_encryption_key_id_out_of_range",
),
],
)
def test_esp32_configuration_errors(
@@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig(
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_nvs_encryption_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that nvs_encryption sets the HMAC scheme sdkconfig options."""
generate_main(component_config_path("nvs_encryption_s3.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True
assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True
assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0
# The permanent/irreversible eFuse burn is warned about at config time.
assert "PERMANENT and IRREVERSIBLE" in caplog.text
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
@@ -0,0 +1,26 @@
esphome:
name: test-not-paused
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
widgets:
- obj:
id: root_obj
@@ -0,0 +1,27 @@
esphome:
name: test-paused
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
paused: true
widgets:
- obj:
id: root_obj
+35
View File
@@ -0,0 +1,35 @@
"""Tests for the LVGL ``paused`` option code generation."""
from __future__ import annotations
import re
_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);")
def _extract_set_paused(main_cpp: str) -> list[str]:
"""Return the normalised argument text of every set_paused() call found.
Whitespace within and around the arguments is collapsed so unrelated
code-generation formatting changes don't break these tests.
"""
return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)]
class TestPausedCodeGeneration:
"""Verify that the ``paused`` option drives the set_paused() call."""
def test_paused_true_generates_set_paused(
self, generate_main, component_config_path
):
"""``paused: true`` emits a set_paused(true, false) call."""
main_cpp = generate_main(component_config_path("paused.yaml"))
calls = _extract_set_paused(main_cpp)
assert calls == ["true, false"]
def test_paused_default_omits_set_paused(
self, generate_main, component_config_path
):
"""Without ``paused`` (default false) no set_paused call is generated."""
main_cpp = generate_main(component_config_path("not_paused.yaml"))
assert _extract_set_paused(main_cpp) == []
@@ -0,0 +1,9 @@
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
<<: !include common.yaml
+3 -14
View File
@@ -24,20 +24,6 @@ binary_sensor:
name: Button A checked
widget: button_a
state: checked
- platform: lvgl
id: button_checker
name: LVGL button
widget: button_button
state: checked
on_state:
then:
- lvgl.checkbox.update:
id: checkbox_id
state:
checked: !lambda |-
auto y = x; // block inlining of one line return
return y;
- platform: lvgl
id: button_presser
name: Button pressed
@@ -49,6 +35,9 @@ lvgl:
rotation: 90
log_level: debug
resume_on_input: true
paused: true
update_when_display_idle: true
refresh_interval: 30ms
on_pause:
- logger.log: LVGL is Paused
- lvgl.display.set_rotation: 90
+2 -3
View File
@@ -1,7 +1,8 @@
packages:
lvgl: !include lvgl-package.yaml
lvgl_package: !include lvgl-package.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
lvgl: !include common.yaml
sensor:
- platform: rotary_encoder
@@ -77,5 +78,3 @@ lvgl:
- component.update: tft_display
- delay: 60s
- lvgl.resume:
<<: !include common.yaml
+11
View File
@@ -4,6 +4,11 @@ audio_dac:
i2c_id: i2c_bus
address: 0x4D
bits_per_sample: 32bit
analog_gain: -6db
channel_mix: swapped
volume_min_db: -60dB
volume_max_db: -3dB
enable_pin: GPIO12
output:
- platform: gpio
@@ -22,3 +27,9 @@ binary_sensor:
number: 4
mode:
input: true
switch:
- platform: pcm5122
pcm5122: pcm5122_dac
name: PCM5122 Power Down
power_mode: powerdown
@@ -0,0 +1,8 @@
esphome:
name: use-address-runtime
host:
api:
logger:
@@ -0,0 +1,9 @@
esphome:
name: use-address-mac
name_add_mac_suffix: true
host:
api:
logger:
+43
View File
@@ -322,6 +322,49 @@ async def test_light_calls(
assert state.state is True
assert state.brightness == pytest.approx(0.75)
# Test 31: Setting brightness to 0 without an explicit state implicitly turns
# the light off; turning it back on (without an explicit brightness) then
# restores full brightness so the light is visible again.
client.light_command(key=rgbcw_light.key, state=True, brightness=0.5)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(0.5)
# Brightness 0 with no explicit state -> implicit turn-off
client.light_command(key=rgbcw_light.key, brightness=0.0)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is False
assert state.brightness == pytest.approx(0.0)
# Turning on without an explicit brightness restores it to full brightness
client.light_command(key=rgbcw_light.key, state=True)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
# Test 31b: An explicit turn-on with brightness 0 still resets to full
# brightness - a turn-on must never leave the light on-but-invisible. This
# is the same path the restore logic exercises (set_state(true) +
# set_brightness(0) from a persisted brightness=0 turn-off).
client.light_command(key=rgbcw_light.key, state=True, brightness=0.0)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(1.0)
# Test 32: Turning a light on when it already has nonzero brightness leaves
# the brightness unchanged (the reset only happens when brightness is 0).
client.light_command(key=rgbcw_light.key, state=True, brightness=0.4)
state = await wait_for_state_change(rgbcw_light.key)
assert state.brightness == pytest.approx(0.4)
client.light_command(key=rgbcw_light.key, state=False)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is False
client.light_command(key=rgbcw_light.key, state=True)
state = await wait_for_state_change(rgbcw_light.key)
assert state.state is True
assert state.brightness == pytest.approx(0.4)
# Final cleanup - turn all lights off
for light in lights:
client.light_command(
@@ -0,0 +1,73 @@
"""Integration tests for the runtime-built use_address.
The default "<name>.local" address is no longer stored as a compile-time string;
it is built at runtime from the device name. This also fixes the logged address
when name_add_mac_suffix is enabled: the baked string used to miss the MAC
suffix, so it never matched the actual mDNS hostname.
"""
from __future__ import annotations
import asyncio
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679"
MAC_SUFFIX = "abf679"
@pytest.mark.asyncio
async def test_use_address_runtime(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The API dump_config logs "<name>.local" built from the device name."""
address_seen = asyncio.Event()
def check_output(line: str) -> None:
if "Address: use-address-runtime.local:" in line:
address_seen.set()
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "use-address-runtime"
try:
await asyncio.wait_for(address_seen.wait(), timeout=10.0)
except TimeoutError:
pytest.fail("Did not log 'Address: use-address-runtime.local:'")
@pytest.mark.asyncio
async def test_use_address_runtime_mac_suffix(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""With name_add_mac_suffix the logged address includes the MAC suffix."""
address_seen = asyncio.Event()
expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:"
def check_output(line: str) -> None:
if expected in line:
address_seen.set()
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == f"use-address-mac-{MAC_SUFFIX}"
try:
await asyncio.wait_for(address_seen.wait(), timeout=10.0)
except TimeoutError:
pytest.fail(f"Did not log '{expected}'")