Merge remote-tracking branch 'origin/dev' into climate-custom-modes-const-vector

# Conflicts:
#	esphome/components/midea/air_conditioner.cpp
This commit is contained in:
J. Nick Koston
2026-10-13 17:04:15 -10:00
49 changed files with 1276 additions and 78 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
@@ -97,7 +97,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: generated-proto-files
path: |
+2 -2
View File
@@ -128,7 +128,7 @@ jobs:
- name: Upload compile-test image artifact
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
# The tar is already compressed, so upload it as-is. archive: false
# skips the redundant zip and makes the file name the artifact name
@@ -210,7 +210,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: compile-test-image.tar.zst
- name: Load image
+11 -11
View File
@@ -49,7 +49,7 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -438,7 +438,7 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -477,7 +477,7 @@ jobs:
# Tests that compare rendered output write the image they actually got here, so a
# failure can be looked at without reproducing the whole build locally.
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-test-artifacts-${{ matrix.bucket.name }}
path: test_artifacts/
@@ -488,7 +488,7 @@ jobs:
# script/update_integration_test_durations.py; only full matrix dev
# runs produce usable data.
if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-integration-${{ strategy.job-index }}
path: junit-integration.xml
@@ -528,7 +528,7 @@ jobs:
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: import-time-waterfall
path: importtime.har
@@ -626,7 +626,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2
with:
run: |
. venv/bin/activate
@@ -1448,7 +1448,7 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -1647,7 +1647,7 @@ jobs:
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -1710,7 +1710,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: memory-analysis-pr
path: memory-analysis-pr.json
@@ -1740,13 +1740,13 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Download target analysis JSON
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: memory-analysis-target
path: ./memory-analysis
continue-on-error: true
- name: Download PR analysis JSON
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: memory-analysis-pr
path: ./memory-analysis
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{matrix.language}}"
+2 -2
View File
@@ -162,7 +162,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -195,7 +195,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: digests-*
path: /tmp/digests
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``prek`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+2 -2
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.10
rev: v0.17.0
hooks:
# Run the linter.
- id: ruff
@@ -36,7 +36,7 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
rev: v3.22.0
hooks:
- id: pyupgrade
args: [--py312-plus]
@@ -87,6 +87,38 @@ jd79660.extend(
),
)
# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later)
#
# Vendor init derived from vendor sample code
# <https://github.com/waveshareteam/e-Paper/blob/master/E-paper_Separate_Program/2in13_e-Paper_G/ESP32/EPD_2in13g_V2.cpp>
# Compatible MIT license, see esphome/LICENSE file.
#
# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML.
# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed
# 124 on this revision. width=124 is declared here (rather than 122) purely so the
# transferred bytes-per-row matches what the vendor init tells the IC to expect; the
# extra 2 columns fall outside the visible glass. The model width must equal the
# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back.
#
# fmt: off
jd79660.extend(
"Waveshare-2.13in-G",
width=124,
height=250,
initsequence=(
(0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value)
(0xE9, 0x01,),
# Power On (0x04): Must be early part of init seq = Disabled later!
(0x04,),
),
fast_update=(
(0xE0, 0x02,),
(0xE6, 0x5A,),
(0xA5,),
),
)
# Waveshare 7.5-H
#
# Vendor init derived from vendor sample code
@@ -130,7 +162,7 @@ jd79660.extend(
# Note: busy pin uses LOW=busy, HIGH=idle.
#
# fmt: off
jd79660.extend(
gdey0213f51 = jd79660.extend(
"goodisplay-gdey0213f51-2.13",
width=128,
height=250,
@@ -154,3 +186,13 @@ jd79660.extend(
(0x04,),
),
)
# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good
# Display entry above, byte for byte, so it only adds the name. Vendor sample:
# <https://github.com/waveshareteam/e-Paper/blob/master/E-paper_Separate_Program/2in13_e-Paper_G/ESP32/EPD_2in13g.cpp>
# Compatible MIT license, see esphome/LICENSE file.
#
# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML.
# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding
# dimensions: in YAML shears the image. Not tested on V1 hardware.
gdey0213f51.extend("Waveshare-2.13in-G-V1")
+22
View File
@@ -6,6 +6,8 @@ import esphome.codegen as cg
from esphome.components import canbus
from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed
from esphome.components.esp32 import (
CONF_MINIMUM_CHIP_REVISION,
KEY_ESP32,
VARIANT_ESP32,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
@@ -20,6 +22,8 @@ from esphome.components.esp32 import (
)
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
CONF_FRAMEWORK,
CONF_ID,
CONF_MODE,
CONF_RX_PIN,
@@ -27,6 +31,7 @@ from esphome.const import (
CONF_TX_PIN,
CONF_TX_QUEUE_LEN,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@Sympatron"]
@@ -49,6 +54,7 @@ CAN_MODES = {
# See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI)
CAN_SPEEDS_ESP32 = {
"20KBPS": CanSpeed.CAN_20KBPS,
"25KBPS": CanSpeed.CAN_25KBPS,
"50KBPS": CanSpeed.CAN_50KBPS,
"100KBPS": CanSpeed.CAN_100KBPS,
@@ -114,6 +120,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend(
)
def _final_validate(config: ConfigType) -> None:
if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS":
esp32_config = fv.full_config.get()[KEY_ESP32]
min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get(
CONF_MINIMUM_CHIP_REVISION
)
if min_revision is None or min_revision < "2.0":
raise cv.Invalid(
"20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher",
path=[CONF_BIT_RATE],
)
FINAL_VALIDATE_SCHEMA = _final_validate
def get_default_tx_enqueue_timeout(bit_rate: str) -> int:
bit_rate_numeric = canbus.get_rate(bit_rate)
bits_per_packet = 140 # ~max CAN message length
@@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config
case canbus::CAN_16KBPS:
*t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS();
return true;
#endif
#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \
defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \
defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200)
case canbus::CAN_20KBPS:
*t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS();
return true;
+19
View File
@@ -3,7 +3,9 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
from esphome.config_helpers import filter_source_files_from_platform, get_logger_level
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
CONF_DISABLED,
CONF_ETHERNET,
CONF_ID,
CONF_OPENTHREAD,
CONF_PORT,
@@ -285,6 +287,23 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# With both wifi and ethernet, the _esphomelib "network" TXT value follows the
# connected interface. OpenThread hands its services to the SRP client once,
# so it keeps the fixed value.
if (
CONF_API in CORE.config
and CONF_WIFI in CORE.config
and CONF_ETHERNET in CORE.config
and CONF_OPENTHREAD not in CORE.config
):
from esphome.components import network
cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT")
# Where the network component tracks the default route (ESP32), follow
# it; otherwise mdns checks the connected interface every second.
if await network.register_active_interface_listener(var):
cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER")
for service in config[CONF_SERVICES]:
txt_records = [
await _mdns_txt_record_templated(var, txt_key, txt_value)
+17 -5
View File
@@ -62,12 +62,9 @@ const char *MDNSComponent::connected_network_() const {
return nullptr;
}
void MDNSComponent::update_network_txt_() {
if (this->network_txt_ == nullptr)
return;
void MDNSComponent::update_network_txt_(const char *network) {
// With no interface connected, keep the last value instead of flapping to wifi
const char *network = this->connected_network_();
if (network == nullptr || network == this->network_txt_)
if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_)
return;
if (!this->publish_network_txt_(network)) {
// The _esphomelib service is missing, so a retry would fail too
@@ -80,6 +77,21 @@ void MDNSComponent::update_network_txt_() {
this->network_record_->value = MDNS_STR(network);
#endif
}
#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER
void MDNSComponent::on_active_interface(network::NetworkInterface interface) {
switch (interface) {
case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET:
this->update_network_txt_(NETWORK_ETHERNET);
break;
case network::NetworkInterface::NETWORK_INTERFACE_WIFI:
this->update_network_txt_(NETWORK_WIFI);
break;
default:
break;
}
}
#endif
#endif
void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) {
+15 -6
View File
@@ -32,10 +32,11 @@
#define USE_MDNS_DEVICE_INFO_TXT
#endif
// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface.
// OpenThread hands its services to the SRP client once, so it keeps the fixed value.
#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD)
#define USE_MDNS_DYNAMIC_NETWORK_TXT
// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the
// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports
// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS.
#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER
#include "esphome/components/network/network_component.h"
#endif
namespace esphome::mdns {
@@ -79,6 +80,10 @@ class MDNSComponent final : public Component
,
public ethernet::EthernetIPStateListener
#endif
#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER
,
public network::ActiveInterfaceListener
#endif
{
public:
void setup() override;
@@ -140,6 +145,10 @@ class MDNSComponent final : public Component
}
#endif
#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER
void on_active_interface(network::NetworkInterface interface) override;
#endif
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1,
const network::IPAddress &dns2) override;
@@ -160,8 +169,8 @@ class MDNSComponent final : public Component
static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000;
/// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr.
const char *connected_network_() const;
/// Check the connected interface and publish a changed "network" TXT value.
void update_network_txt_();
/// Publish network as the "network" TXT value if it changed; nullptr keeps the current value.
void update_network_txt_(const char *network);
/// Platform-specific: replace the "network" TXT value of the registered _esphomelib service.
bool publish_network_txt_(const char *network);
/// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish.
+3 -2
View File
@@ -103,8 +103,9 @@ bool MDNSComponent::publish_network_txt_(const char *network) {
void MDNSComponent::setup() {
this->setup_buffers_and_register_(register_esp32);
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); });
#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER)
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS,
[this]() { this->update_network_txt_(this->connected_network_()); });
#endif
}
+2 -1
View File
@@ -64,7 +64,8 @@ bool MDNSComponent::publish_network_txt_(const char *network) {
void MDNSComponent::setup() {
#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT
// No-op until the first IP-up event registers the services
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); });
this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS,
[this]() { this->update_network_txt_(this->connected_network_()); });
#endif
// arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses
// on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our
+37 -18
View File
@@ -9,8 +9,6 @@
namespace esphome::midea::ac {
ESPHOME_LOG_TAG(TAG, "midea");
static void set_sensor(Sensor *sensor, float value) {
if (sensor != nullptr && (!sensor->has_state() || sensor->get_raw_state() != value))
sensor->publish_state(value);
@@ -129,23 +127,40 @@ ClimateTraits AirConditioner::traits() {
return traits;
}
#ifdef USE_REMOTE_TRANSMITTER
static const LogString *display_control_str(DisplayControl control) {
switch (control) {
case DisplayControl::DISPLAY_CONTROL_UART:
return LOG_STR("UART");
case DisplayControl::DISPLAY_CONTROL_IR:
return LOG_STR("IR");
default:
return LOG_STR("AUTO");
}
}
#endif
void AirConditioner::dump_config() {
ESP_LOGCONFIG(TAG,
ESP_LOGCONFIG(Constants::TAG,
"MideaDongle:\n"
" [x] Period: %" PRIu32 "ms\n"
" [x] Response timeout: %" PRIu32 "ms\n"
" [x] Request attempts: %d",
this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts());
#ifdef USE_REMOTE_TRANSMITTER
ESP_LOGCONFIG(TAG, " [x] Using RemoteTransmitter");
ESP_LOGCONFIG(Constants::TAG,
" [x] Using RemoteTransmitter\n"
" [x] Display control: %s",
LOG_STR_ARG(display_control_str(this->display_control_)));
#endif
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) {
this->base_.getCapabilities().dump();
} else if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_ERROR) {
ESP_LOGW(TAG, "Failed to get 0xB5 capabilities report. Suggest to disable it in config and manually set your "
"appliance options.");
ESP_LOGW(Constants::TAG,
"Failed to get 0xB5 capabilities report. Suggest to disable it in config and manually set your "
"appliance options.");
}
this->dump_traits_(TAG);
this->dump_traits_(Constants::TAG);
}
/* ACTIONS */
@@ -154,7 +169,7 @@ void AirConditioner::do_follow_me(float temperature, bool use_fahrenheit, bool b
#ifdef USE_REMOTE_TRANSMITTER
// Check if temperature is finite (not NaN or infinite)
if (!std::isfinite(temperature)) {
ESP_LOGW(TAG, "Follow me action requires a finite temperature, got: %f", temperature);
ESP_LOGW(Constants::TAG, "Follow me action requires a finite temperature, got: %f", temperature);
return;
}
@@ -163,14 +178,14 @@ void AirConditioner::do_follow_me(float temperature, bool use_fahrenheit, bool b
static_cast<uint8_t>(esphome::clamp<long>(std::lroundf(temperature), 0L, static_cast<long>(UINT8_MAX)));
char temp_symbol = use_fahrenheit ? 'F' : 'C';
ESP_LOGD(TAG, "Follow me action called with temperature: %.5f °%c, rounded to: %u °%c", temperature, temp_symbol,
temp_uint8, temp_symbol);
ESP_LOGD(Constants::TAG, "Follow me action called with temperature: %.5f °%c, rounded to: %u °%c", temperature,
temp_symbol, temp_uint8, temp_symbol);
// Create and transmit the data
IrFollowMeData data(temp_uint8, use_fahrenheit, beeper);
this->transmitter_.transmit(data);
#else
ESP_LOGW(TAG, "Action needs remote_transmitter component");
ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component");
#endif
}
@@ -179,21 +194,25 @@ void AirConditioner::do_swing_step() {
IrSpecialData data(0x01);
this->transmitter_.transmit(data);
#else
ESP_LOGW(TAG, "Action needs remote_transmitter component");
ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component");
#endif
}
void AirConditioner::do_display_toggle() {
if (this->base_.getCapabilities().supportLightControl()) {
this->base_.displayToggle();
} else {
#ifdef USE_REMOTE_TRANSMITTER
// AUTO uses IR unless the appliance reports light control over UART. Without a transmitter
// built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it
// without advertising it in the 0xB5 capabilities report.
const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR ||
(this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO &&
!this->base_.getCapabilities().supportLightControl());
if (use_ir) {
IrSpecialData data(0x08);
this->transmitter_.transmit(data);
#else
ESP_LOGW(TAG, "Action needs remote_transmitter component");
#endif
return;
}
#endif
this->base_.displayToggle();
}
} // namespace esphome::midea::ac
@@ -21,12 +21,20 @@ using climate::ClimateModeMask;
using climate::ClimateSwingModeMask;
using climate::ClimatePresetMask;
// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle()
enum class DisplayControl : uint8_t {
DISPLAY_CONTROL_UART, // always the UART request
DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR
DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter
};
class AirConditioner final : public ApplianceBase<dudanov::midea::ac::AirConditioner>, public climate::Climate {
public:
void dump_config() override;
void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; }
void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; }
void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; }
void set_display_control(DisplayControl control) { this->display_control_ = control; }
void on_status_change() override;
/* ############### */
@@ -52,6 +60,7 @@ class AirConditioner final : public ApplianceBase<dudanov::midea::ac::AirConditi
ClimateSwingModeMask supported_swing_modes_{};
ClimatePresetMask supported_presets_{};
bool frost_protection_set_{false};
DisplayControl display_control_{DisplayControl::DISPLAY_CONTROL_AUTO};
Sensor *outdoor_sensor_{nullptr};
Sensor *humidity_sensor_{nullptr};
Sensor *power_sensor_{nullptr};
+24
View File
@@ -33,15 +33,18 @@ from esphome.const import (
UNIT_WATT,
)
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@dudanov"]
DEPENDENCIES = ["climate", "uart"]
AUTO_LOAD = ["sensor"]
CONF_POWER_USAGE = "power_usage"
CONF_HUMIDITY_SETPOINT = "humidity_setpoint"
CONF_DISPLAY_CONTROL = "display_control"
midea_ac_ns = cg.esphome_ns.namespace("midea").namespace("ac")
AirConditioner = midea_ac_ns.class_("AirConditioner", climate.Climate, cg.Component)
Capabilities = midea_ac_ns.namespace("Constants")
DisplayControl = midea_ac_ns.enum("DisplayControl", is_class=True)
def templatize(value):
@@ -82,11 +85,29 @@ CUSTOM_PRESETS = {
"FREEZE_PROTECTION": Capabilities.FREEZE_PROTECTION,
}
# How midea_ac.display_toggle reaches the appliance; see do_display_toggle() for the AUTO rules
DISPLAY_CONTROLS = {
"UART": DisplayControl.DISPLAY_CONTROL_UART,
"AUTO": DisplayControl.DISPLAY_CONTROL_AUTO,
"IR": DisplayControl.DISPLAY_CONTROL_IR,
}
validate_modes = cv.enum(ALLOWED_CLIMATE_MODES, upper=True)
validate_presets = cv.enum(ALLOWED_CLIMATE_PRESETS, upper=True)
validate_swing_modes = cv.enum(ALLOWED_CLIMATE_SWING_MODES, upper=True)
validate_custom_fan_modes = cv.enum(CUSTOM_FAN_MODES, upper=True)
validate_custom_presets = cv.enum(CUSTOM_PRESETS, upper=True)
validate_display_control = cv.enum(DISPLAY_CONTROLS, upper=True)
def validate_display_control_with_transmitter(config: ConfigType) -> ConfigType:
"""`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not."""
if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config:
raise cv.Invalid(
f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set"
)
return config
CONFIG_SCHEMA = cv.All(
climate.climate_schema(AirConditioner)
@@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_BEEPER, default=False): cv.boolean,
cv.Optional(CONF_AUTOCONF, default=True): cv.boolean,
cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control,
cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes),
cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list(
validate_swing_modes
@@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
validate_display_control_with_transmitter,
cv.only_on(
[
PLATFORM_ESP32,
@@ -204,6 +227,7 @@ async def to_code(config):
cg.add(var.set_transmitter(transmitter_))
cg.add(var.set_beeper_feedback(config[CONF_BEEPER]))
cg.add(var.set_autoconf(config[CONF_AUTOCONF]))
cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL]))
if CONF_SUPPORTED_MODES in config:
cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES]))
if CONF_SUPPORTED_SWING_MODES in config:
+32 -1
View File
@@ -100,6 +100,37 @@ if (
network_ns = cg.esphome_ns.namespace("network")
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
_request_active_interface_listener_slot = cg.slot_counter(
"NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT"
)
def has_active_interface() -> bool:
"""Return True when NetworkComponent tracks the interface holding the default route.
That is ESP32 with more than one interface in 'network: priority:', the same
condition that emits USE_NETWORK_DEFAULT_ROUTE.
"""
return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1
async def register_active_interface_listener(listener: cg.MockObj) -> bool:
"""Register an ActiveInterfaceListener with the NetworkComponent.
The listener is notified from the main loop when the interface holding the
default route changes. Returns False, and registers nothing, when
has_active_interface() is False; the caller then needs another way to
follow interface changes.
"""
if not has_active_interface():
return False
_request_active_interface_listener_slot()
network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID])
cg.add(network.add_active_interface_listener(listener))
return True
IPAddress = network_ns.class_("IPAddress")
@@ -402,7 +433,7 @@ async def to_code(config: ConfigType) -> None:
# frameworks build from source.
# The ethernet/wifi-only assumption behind the arbitration is enforced in
# _final_validate() so a future unsupported type fails as a config error.
if len(priority_list) > 1 and CORE.is_esp32:
if has_active_interface():
cg.add_define("USE_NETWORK_DEFAULT_ROUTE")
# Have lwIP switch to the DNS servers of the netif that owns the
# default route whenever the arbitration changes it.
@@ -78,16 +78,24 @@ void NetworkComponent::loop() {
// USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list;
// it selects the reported address in util.cpp and doubles as the route-order
// pivot here — the two uses must stay in sync.
esp_netif_t *best;
esp_netif_t *wifi = connected_wifi_netif();
esp_netif_t *ethernet = connected_ethernet_netif();
#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI
best = connected_wifi_netif();
if (best == nullptr)
best = connected_ethernet_netif();
esp_netif_t *best = wifi != nullptr ? wifi : ethernet;
#else
best = connected_ethernet_netif();
if (best == nullptr)
best = connected_wifi_netif();
esp_netif_t *best = ethernet != nullptr ? ethernet : wifi;
#endif
NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE;
if (best != nullptr) {
active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI;
}
if (active != this->active_interface_) {
this->active_interface_ = active;
#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT
for (auto *listener : this->active_interface_listeners_)
listener->on_active_interface(active);
#endif
}
if (best == nullptr) {
// Forget the last winner: stopping its netif cleared lwIP's default route and
// IDF's manual override suppresses re-election, so reconnect must re-assert it.
@@ -4,12 +4,32 @@
#include "esphome/core/component.h"
#ifdef USE_NETWORK_DEFAULT_ROUTE
#include "esphome/core/helpers.h"
// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h
// into this header.
using esp_netif_t = struct esp_netif_obj;
#endif
namespace esphome::network {
#ifdef USE_NETWORK_DEFAULT_ROUTE
/// The interface holding the default route. Lists only the interfaces loop() arbitrates
/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together.
enum class NetworkInterface : uint8_t {
NETWORK_INTERFACE_NONE,
NETWORK_INTERFACE_ETHERNET,
NETWORK_INTERFACE_WIFI,
};
#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT
/// Notified from the main loop when the interface holding the default route changes.
class ActiveInterfaceListener {
public:
virtual void on_active_interface(NetworkInterface interface) = 0;
};
#endif
#endif
class NetworkComponent final : public Component {
public:
void setup() override;
@@ -20,6 +40,14 @@ class NetworkComponent final : public Component {
#ifdef USE_NETWORK_DEFAULT_ROUTE
void loop() override;
/// The first connected interface in network: priority order; NONE while none is connected.
NetworkInterface get_active_interface() const { return this->active_interface_; }
#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT
void add_active_interface_listener(ActiveInterfaceListener *listener) {
this->active_interface_listeners_.push_back(listener);
}
#endif
protected:
// Verify-lwIP-route interval for the settled state; keeps the global lwIP core
// mutex off the hot loop path.
@@ -27,6 +55,10 @@ class NetworkComponent final : public Component {
// Last netif this component made the default; avoids redundant esp_netif calls.
esp_netif_t *default_netif_{nullptr};
uint32_t last_route_check_{0};
#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT
StaticVector<ActiveInterfaceListener *, NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT> active_interface_listeners_;
#endif
NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE};
#endif
};
} // namespace esphome::network
+109 -2
View File
@@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts
// Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time.
static constexpr uint32_t TIMEOUT_POLL = 1;
static constexpr uint32_t TIMEOUT_SETUP_STEP = 2;
static constexpr uint32_t TIMEOUT_STARTUP = 3;
// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2),
// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1)
static constexpr uint32_t START_MEASUREMENT_DELAY = 50;
static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400;
static constexpr uint32_t FAN_CLEANING_DELAY = 10000;
static constexpr uint32_t SHT_HEATER_DELAY = 20000;
static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000;
static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid
static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202;
static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100;
static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014;
@@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467;
static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5;
static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021;
static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104;
static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607;
static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765;
static constexpr uint16_t SEN6X_CMD_RESET = 0xD304;
static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0;
static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1;
@@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() {
return;
}
this->set_timeout(60000, [this]() { this->startup_complete_ = true; });
this->on_measurement_started_();
this->initialized_ = true;
ESP_LOGD(TAG, "Initialized");
}
@@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() {
}
void SEN6XComponent::update() {
if (!this->initialized_) {
if (!this->initialized_ || !this->measuring_) {
return;
}
@@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() {
this->status_clear_warning();
}
// True while the previous command's execution or settling time has not elapsed
bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; }
void SEN6XComponent::set_command_wait_(uint32_t wait_ms) {
this->command_started_at_ = millis();
this->command_wait_ = wait_ms;
}
void SEN6XComponent::on_measurement_started_() {
this->measuring_ = true;
this->set_command_wait_(START_MEASUREMENT_DELAY);
// SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then
if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) {
this->co2_started_at_ = millis();
this->co2_wait_ = CO2_CONDITIONING_DELAY;
}
// Values need the warm-up period again after every start
this->startup_complete_ = false;
this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; });
}
void SEN6XComponent::start_measurement() {
if (!this->initialized_ || this->measuring_)
return;
// The CO2 conditioning window only blocks restarting, not other commands
if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) {
ESP_LOGW(TAG, "Device busy");
return;
}
if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) {
this->status_set_warning();
ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_);
return;
}
this->on_measurement_started_();
}
void SEN6XComponent::stop_measurement() {
if (!this->initialized_ || !this->measuring_)
return;
if (this->command_blocked_()) {
ESP_LOGW(TAG, "Device busy");
return;
}
// Stop any in-flight polling before the device goes idle
this->cancel_timeout(TIMEOUT_POLL);
if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) {
this->status_set_warning();
ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_);
return;
}
this->measuring_ = false;
this->set_command_wait_(STOP_MEASUREMENT_DELAY);
}
void SEN6XComponent::start_fan_cleaning() {
if (!this->initialized_)
return;
// The device only accepts this command in idle mode (datasheet section 4.8.22)
if (this->measuring_) {
ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first");
return;
}
if (this->command_blocked_()) {
ESP_LOGW(TAG, "Device busy");
return;
}
if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) {
this->status_set_warning();
ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_);
return;
}
this->set_command_wait_(FAN_CLEANING_DELAY);
}
void SEN6XComponent::activate_sht_heater() {
if (!this->initialized_)
return;
// The device only accepts this command in idle mode (datasheet section 4.8.23)
if (this->measuring_) {
ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first");
return;
}
if (this->command_blocked_()) {
ESP_LOGW(TAG, "Device busy");
return;
}
if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) {
this->status_set_warning();
ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_);
return;
}
this->set_command_wait_(SHT_HEATER_DELAY);
}
SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) {
if (product_name == "SEN62")
return SEN62;
+13
View File
@@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S
NOX_STD_INITIAL,
gain_factor};
}
void start_measurement();
void stop_measurement();
void start_fan_cleaning();
void activate_sht_heater();
protected:
Sen6xType infer_type_from_product_name_(const std::string &product_name);
@@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S
void finish_setup_();
bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len);
bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning);
bool command_blocked_() const;
void set_command_wait_(uint32_t wait_ms);
void on_measurement_started_();
void poll_data_ready_();
void read_measurements_();
void parse_and_publish_measurements_();
@@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S
optional<GasTuning> voc_tuning_params_;
optional<GasTuning> nox_tuning_params_;
Sen6xType sen6x_type_{UNKNOWN};
// Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap
uint32_t command_started_at_{0};
uint32_t command_wait_{0};
uint32_t co2_started_at_{0};
uint32_t co2_wait_{0};
uint16_t read_cmd_{0};
uint8_t setup_step_index_{0};
uint8_t firmware_version_major_{0};
@@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S
uint8_t poll_retries_remaining_{0};
uint8_t read_words_{0};
bool initialized_{false};
bool measuring_{false};
bool startup_complete_{false};
};
+20
View File
@@ -1,3 +1,5 @@
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import i2c, sensirion_common, sensor
from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX
@@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None:
args.append(std_initial)
args.append(tuning[CONF_GAIN_FACTOR])
cg.add(getattr(var, setter)(*args))
SEN6X_ACTION_SCHEMA = maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(SEN6XComponent),
}
)
for _name, _call in (
("sen6x.start_measurement", "start_measurement()"),
("sen6x.stop_measurement", "stop_measurement()"),
("sen6x.start_fan_cleaning", "start_fan_cleaning()"),
("sen6x.activate_sht_heater", "activate_sht_heater()"),
):
automation.register_apply_action(
_name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call)
)
+14
View File
@@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current"
CONF_PRECHARGE_CURRENT = "precharge_current"
CONF_CHARGE_ENABLED = "charge_enabled"
CONF_ENABLE_ADC = "enable_adc"
CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout"
sy6970_ns = cg.esphome_ns.namespace("sy6970")
SY6970Component = sy6970_ns.class_(
"SY6970Component", cg.PollingComponent, i2c.I2CDevice
)
SY6970Listener = sy6970_ns.class_("SY6970Listener")
I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout")
# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on
I2C_WATCHDOG_TIMEOUTS = {
"DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED,
"40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S,
"80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S,
"160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S,
}
CONFIG_SCHEMA = (
cv.Schema(
@@ -43,6 +53,9 @@ CONFIG_SCHEMA = (
),
cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean,
cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean,
cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum(
I2C_WATCHDOG_TIMEOUTS, upper=True
),
}
)
.extend(cv.polling_component_schema("5s"))
@@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None:
config[CONF_PRECHARGE_CURRENT],
config[CONF_CHARGE_ENABLED],
config[CONF_ENABLE_ADC],
config[CONF_I2C_WATCHDOG_TIMEOUT],
)
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
+68 -2
View File
@@ -6,6 +6,25 @@ namespace esphome::sy6970 {
ESPHOME_LOG_TAG(TAG, "sy6970");
static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) {
switch (timeout) {
case I2C_WATCHDOG_DISABLED:
return LOG_STR("Disabled");
case I2C_WATCHDOG_40S:
return LOG_STR("40s");
case I2C_WATCHDOG_80S:
return LOG_STR("80s");
case I2C_WATCHDOG_160S:
return LOG_STR("160s");
default:
return LOG_STR("Unknown");
}
}
static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0;
// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin
static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000;
bool SY6970Component::read_all_registers_() {
// Read all registers from 0x00 to 0x14 in one transaction (21 bytes)
// This includes unused registers 0x0F, 0x10 for performance
@@ -51,6 +70,11 @@ void SY6970Component::setup() {
ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id);
}
// Service the watchdog first, so the settings below are not reverted while they are written
ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s",
LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_)));
this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_);
// Apply configuration options (all have defaults now)
ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_));
this->set_led_enabled(this->led_enabled_);
@@ -85,9 +109,11 @@ void SY6970Component::dump_config() {
" Charge Current: %u mA\n"
" Precharge Current: %u mA\n"
" Charge Enabled: %s\n"
" ADC Enabled: %s",
" ADC Enabled: %s\n"
" I2C Watchdog Timeout: %s",
ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_,
this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_));
this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_),
LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_)));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
if (this->is_failed()) {
@@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) {
this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00);
}
void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) {
if (this->is_failed())
return;
if (timeout != I2C_WATCHDOG_DISABLED) {
// Changing the timeout does not restart the chip's timer, which may be close to expiring
this->reset_i2c_watchdog();
}
// REG07 bits 5:4 (WATCHDOG[1:0])
bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast<uint8_t>(timeout) << 4);
if (!written) {
ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout)));
}
if (timeout == I2C_WATCHDOG_DISABLED && written) {
this->i2c_watchdog_timeout_ = timeout;
this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID);
return;
}
if (timeout != I2C_WATCHDOG_DISABLED) {
this->i2c_watchdog_timeout_ = timeout;
}
// Keeps kicking after a failed write too: the chip may still be on its 40 s default
this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS,
[this]() { this->reset_i2c_watchdog(); });
}
bool SY6970Component::reset_i2c_watchdog() {
if (this->is_failed())
return false;
// WD_RST, self-clearing
if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) {
ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert");
return false;
}
return true;
}
} // namespace esphome::sy6970
+18 -2
View File
@@ -62,6 +62,15 @@ enum ChargeStatus {
CHARGE_STATUS_CHARGE_DONE = 3,
};
// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults,
// so an enabled watchdog is kicked on its own interval.
enum I2CWatchdogTimeout {
I2C_WATCHDOG_DISABLED = 0,
I2C_WATCHDOG_40S = 1,
I2C_WATCHDOG_80S = 2,
I2C_WATCHDOG_160S = 3,
};
// Structure to hold all register data read in one transaction
struct SY6970Data {
uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10)
@@ -76,14 +85,16 @@ class SY6970Listener {
class SY6970Component final : public PollingComponent, public i2c::I2CDevice {
public:
SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current,
uint16_t precharge_current, bool charge_enabled, bool enable_adc)
uint16_t precharge_current, bool charge_enabled, bool enable_adc,
I2CWatchdogTimeout i2c_watchdog_timeout)
: led_enabled_(led_enabled),
input_current_limit_(input_current_limit),
charge_voltage_(charge_voltage),
charge_current_(charge_current),
precharge_current_(precharge_current),
charge_enabled_(charge_enabled),
enable_adc_(enable_adc) {}
enable_adc_(enable_adc),
i2c_watchdog_timeout_(i2c_watchdog_timeout) {}
void setup() override;
void dump_config() override;
void update() override;
@@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice {
void set_charge_enabled(bool enabled);
void set_led_enabled(bool enabled);
void set_enable_adc_measure(bool enabled = true);
void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout);
/// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written.
bool reset_i2c_watchdog();
protected:
bool read_all_registers_();
@@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice {
uint16_t precharge_current_;
bool charge_enabled_;
bool enable_adc_;
I2CWatchdogTimeout i2c_watchdog_timeout_;
};
} // namespace esphome::sy6970
+2 -2
View File
@@ -1,4 +1,4 @@
import datetime
import datetime as dt
import random
import esphome.codegen as cg
@@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None:
basic_attrs.date_code,
# Local build time, matching the esp32 implementation
# (App.get_build_time() in C++).
datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"),
dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"),
),
zigbee_assign(
basic_attrs.power_source,
+6
View File
@@ -217,6 +217,7 @@
#define TCA8418_LISTENER_COUNT 1
#define USE_MEDIA_PLAYER
#define USE_MEDIA_SOURCE
#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1
#define USE_NETWORK
#define USE_NETWORK_DEFAULT_ROUTE
#define USE_NETWORK_PRIMARY_INTERFACE_WIFI
@@ -567,6 +568,10 @@
#endif
#ifndef USE_OPENTHREAD
#define USE_MDNS_SUPPORTS_ENABLE_DISABLE
#ifdef USE_ETHERNET
#define USE_MDNS_ACTIVE_INTERFACE_LISTENER
#define USE_MDNS_DYNAMIC_NETWORK_TXT
#endif
#endif
#endif
@@ -647,6 +652,7 @@
#define USE_ETHERNET_SPI
#endif
#define USE_ETHERNET_W5500
#define USE_MDNS_DYNAMIC_NETWORK_TXT
#define USE_WIFI_IP_STATE_LISTENERS
#define ESPHOME_WIFI_IP_STATE_LISTENERS 2
#define USE_ETHERNET_IP_STATE_LISTENERS
+4 -4
View File
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
tzdata>=2026.5 # from time
pyserial==3.5
platformio==6.2.0
esptool==5.4.0
esptool==5.5.0
click==8.5.0
aioesphomeapi==46.10.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
@@ -19,7 +19,7 @@ puremagic==2.2.0
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14
backports.zstd==1.8.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14
pillow==12.3.0
resvg-py==0.5.0
freetype-py==2.5.1
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.3
platformdirs==4.12.3 # native esp-idf toolchain global cache dir
platformdirs==4.12.4 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.3
+2 -2
View File
@@ -1,7 +1,7 @@
pylint==4.1.2
flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
prek==0.5.5 # .github/workflows/ci.yml reads this pin
yamlrocks==0.6.1 # used by script/sync_dependency_versions.py
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Loops that only exist on boards with PSRAM are gated on this define;
# set it so the host benchmark measures the same code those boards run.
add_defines_to_code(manifest, "USE_PSRAM")
@@ -0,0 +1,82 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <memory>
#include "esphome/components/display/display_buffer.h"
namespace esphome::display::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// Square frame so a rotated block stays inside it.
static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight;
// A buffered display without its own draw_pixels_at(), so every block goes
// through the base class fallback and the DisplayBuffer pixel path.
class BenchDisplay : public DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kStride * kStride)) {}
void update() override {}
DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kStride; }
int get_height_internal() override { return kStride; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
};
// Deterministic pseudo random pixel bytes, sized for the widest format.
static std::unique_ptr<uint8_t[]> make_pixels() {
constexpr size_t bytes = kWidth * kHeight * 3;
auto data = std::make_unique<uint8_t[]>(bytes);
uint32_t seed = 11897;
for (size_t i = 0; i < bytes; i++) {
seed = seed * 1103515245u + 12345u;
data[i] = static_cast<uint8_t>(seed >> 16);
}
return data;
}
// One full block the way LVGL hands a display its rendering.
static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian,
DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) {
auto data = make_pixels();
BenchDisplay display;
display.set_rotation(rotation);
for (auto _ : state) {
display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); }
BENCHMARK(DrawPixelsAt_RGB565_BigEndian);
static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); }
BENCHMARK(DrawPixelsAt_RGB565_LittleEndian);
static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); }
BENCHMARK(DrawPixelsAt_RGB888);
static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); }
BENCHMARK(DrawPixelsAt_RGB332);
static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) {
draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(DrawPixelsAt_RGB565_Rotated);
static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) {
draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(DrawPixelsAt_RGB888_Rotated);
static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) {
draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(DrawPixelsAt_RGB332_Rotated);
} // namespace esphome::display::benchmarks
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Loops that only exist on boards with PSRAM are gated on this define;
# set it so the host benchmark measures the same code those boards run.
add_defines_to_code(manifest, "USE_PSRAM")
@@ -0,0 +1,245 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <cstring>
#include <memory>
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/image/image.h"
namespace esphome::image::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// Square frame so a rotated draw of the image stays inside it.
static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight;
// One byte per pixel frame buffer with no hardware behind it, so the
// measurement is Image::draw() plus the DisplayBuffer pixel path every
// buffered display shares. Everything fits the host cache, so this counts
// the per pixel work, not what a frame buffer in PSRAM costs.
class BenchDisplay : public display::DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kStride * kStride)) {}
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kStride; }
int get_height_internal() override { return kStride; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
public:
uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; }
};
// Models a driver with a bulk path: rows of little endian RGB565 are copied
// straight into a 16 bit frame, as the TFT drivers do for their own format.
// Image::draw() only reaches it once it hands opaque images to
// draw_pixels_at(); until then this case matches ImageDraw_RGB565.
class BulkBenchDisplay : public BenchDisplay {
public:
BulkBenchDisplay() : frame565_(std::make_unique<uint16_t[]>(kStride * kStride)) {}
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override {
if (bitness != display::COLOR_BITNESS_565 || big_endian) {
BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
const size_t stride = x_offset + w + x_pad;
for (int y = 0; y < h; y++) {
memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2,
w * 2);
}
}
protected:
std::unique_ptr<uint16_t[]> frame565_;
};
// Draws the image at rotation 0 and 90 and checks the second frame is the
// transpose of the first, which is what a wrong traversal order would break.
static bool frames_match(Image &image) {
BenchDisplay plain;
BenchDisplay rotated;
rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES);
image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF);
image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF);
for (int y = 0; y < kHeight; y++) {
for (int x = 0; x < kWidth; x++) {
// DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees
if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x))
return false;
}
}
return true;
}
// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of
// pixels. Sized for the widest format, the image reads only what it needs.
static std::unique_ptr<uint8_t[]> make_pixels() {
constexpr size_t bytes = kWidth * kHeight * 4;
auto data = std::make_unique<uint8_t[]>(bytes);
uint32_t seed = 11897;
for (size_t i = 0; i < bytes; i++) {
seed = seed * 1103515245u + 12345u;
data[i] = static_cast<uint8_t>(seed >> 16);
}
return data;
}
static void draw_image(benchmark::State &state, ImageType type, Transparency transparency,
display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) {
auto data = make_pixels();
Image image(data.get(), kWidth, kHeight, type, transparency);
BenchDisplay display;
display.set_rotation(rotation);
if (!frames_match(image)) {
state.SkipWithError("rotated draw is not the transpose of the plain draw");
return;
}
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_Binary);
static void ImageDraw_Grayscale(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE);
}
BENCHMARK(ImageDraw_Grayscale);
static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB565);
static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB);
// The same opaque RGB565 image on a display with a bulk path.
static void ImageDraw_RGB565_Bulk(benchmark::State &state) {
auto data = make_pixels();
Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE);
BulkBenchDisplay display;
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
BENCHMARK(ImageDraw_RGB565_Bulk);
static void ImageDraw_RGB_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_Rotated);
static void ImageDraw_Binary_Transparent(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_Binary_Transparent);
static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_Grayscale_ChromaKey);
static void ImageDraw_Grayscale_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_Grayscale_Alpha);
static void ImageDraw_RGB_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB_Alpha);
static void ImageDraw_RGB_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_RGB_ChromaKey);
static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_RGB565_ChromaKey);
static void ImageDraw_RGB565_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB565_Alpha);
static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB111);
static void ImageDraw_RGB111_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB111_Alpha);
// The same cases with the display rotated 90 degrees, which walks the other loop order.
static void ImageDraw_Binary_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Binary_Rotated);
static void ImageDraw_Grayscale_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Grayscale_Rotated);
static void ImageDraw_RGB565_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB565_Rotated);
static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Binary_Transparent_Rotated);
static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated);
static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated);
static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_Alpha_Rotated);
static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated);
static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated);
static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB565_Alpha_Rotated);
static void ImageDraw_RGB111_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB111_Rotated);
static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB111_Alpha_Rotated);
} // namespace esphome::image::benchmarks
@@ -0,0 +1,125 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <memory>
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// One byte per pixel frame buffer with no hardware behind it, so the
// measurement is the image draw plus the DisplayBuffer pixel path every
// buffered display shares.
class BenchDisplay : public display::DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kWidth * kHeight)) {}
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kWidth; }
int get_height_internal() override { return kHeight; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
};
// A runtime image filled the way a decoder fills it, one draw_pixel() per
// pixel, then published as decoded so draw() uses the buffer.
class BenchImage : public RuntimeImage {
public:
BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian)
: RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {}
bool fill() {
if (this->resize(kWidth, kHeight) == 0)
return false;
uint32_t seed = 11897;
for (int y = 0; y < kHeight; y++) {
for (int x = 0; x < kWidth; x++) {
seed = seed * 1103515245u + 12345u;
// Every fourth pixel is transparent so the chroma key cases hit both branches
const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF;
this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha));
}
}
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
return true;
}
};
// The decoder side: writing every pixel of the image into its buffer.
static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) {
BenchImage image(type, transparency, false);
for (auto _ : state) {
if (!image.fill()) {
state.SkipWithError("buffer allocation failed");
return;
}
benchmark::DoNotOptimize(image.get_data_start());
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
// The display side: drawing the decoded image onto a buffered display.
static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency,
bool big_endian = false) {
BenchImage image(type, transparency, big_endian);
if (!image.fill()) {
state.SkipWithError("buffer allocation failed");
return;
}
BenchDisplay display;
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void RuntimeImageFill_RGB565(benchmark::State &state) {
fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageFill_RGB565);
static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) {
fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(RuntimeImageFill_RGB565_ChromaKey);
static void RuntimeImageFill_RGB(benchmark::State &state) {
fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageFill_RGB);
static void RuntimeImageDraw_RGB565(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageDraw_RGB565);
static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true);
}
BENCHMARK(RuntimeImageDraw_RGB565_BigEndian);
static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey);
static void RuntimeImageDraw_RGB(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageDraw_RGB);
static void RuntimeImageDraw_Grayscale(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageDraw_Grayscale);
} // namespace esphome::runtime_image::benchmarks
@@ -0,0 +1,28 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
api:
@@ -0,0 +1,26 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
mac_address: "02:AA:BB:CC:DD:01"
network:
priority:
- ethernet
- wifi
api:
@@ -0,0 +1,52 @@
"""mDNS follows the interface holding the default route through an
ActiveInterfaceListener, only where NetworkComponent tracks that interface
(ESP32 with more than one interface) and the network TXT record exists."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT"
LISTENER_DEFINES = {
"USE_MDNS_ACTIVE_INTERFACE_LISTENER",
"NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT",
}
def _defines() -> set[str]:
return {define.name for define in CORE.defines}
def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml"))
assert "add_active_interface_listener(" in main_cpp
assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE}
@pytest.mark.parametrize(
("fixture", "dynamic_txt"),
[
# No api means no _esphomelib service, so no network TXT record
pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"),
# No route arbitration on RP2; mDNS keeps its periodic check there
pytest.param("priority_rp2040_api.yaml", True, id="rp2040"),
],
)
def test_mdns_does_not_register(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
fixture: str,
dynamic_txt: bool,
) -> None:
main_cpp = generate_main(component_config_path(fixture))
assert "add_active_interface_listener(" not in main_cpp
assert not LISTENER_DEFINES & _defines()
assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt
@@ -87,6 +87,40 @@ display:
number: GPIO4
inverted: true
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-g
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-g-v1
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
- platform: epaper_spi
model: seeed-reterminal-e1002
- platform: epaper_spi
@@ -0,0 +1,11 @@
esp32:
framework:
advanced:
minimum_chip_revision: "2.0"
canbus:
- platform: esp32_can
rx_pin: GPIO14
tx_pin: GPIO12
can_id: 4
bit_rate: 20kbps
@@ -0,0 +1,6 @@
climate:
- platform: midea
id: midea_uart_only
name: Midea Climate UART Only
autoconf: true
display_control: UART
+1
View File
@@ -33,6 +33,7 @@ climate:
timeout: 2s
beeper: false
autoconf: true
display_control: AUTO
visual:
min_temperature: 17 °C
max_temperature: 30 °C
@@ -0,0 +1,8 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
midea: !include common-uart_only.yaml
# The MideaUART library includes esp_wifi.h on ESP-IDF
wifi:
ssid: MySSID
password: password1
+14
View File
@@ -48,3 +48,17 @@ sensor:
formaldehyde:
name: Formaldehyde
address: 0x6B
button:
- platform: template
name: Fan Cleaning
on_press:
- sen6x.stop_measurement: sen6x_sensor
- delay: 2s
- sen6x.start_fan_cleaning: sen6x_sensor
- delay: 11s
- sen6x.start_measurement: sen6x_sensor
- platform: template
name: SHT Heater
on_press:
- sen6x.activate_sht_heater: sen6x_sensor
+1
View File
@@ -9,6 +9,7 @@ sy6970:
precharge_current: 128
charge_enabled: true
enable_adc: true
i2c_watchdog_timeout: 80S
update_interval: 5s
sensor:
+22
View File
@@ -1,6 +1,7 @@
from typing import Any
from esphome.loader import ComponentManifest, _replace_component_manifest
from esphome.types import ConfigType
class ComponentManifestOverride:
@@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No
directory provides an ``override_manifest`` function.
"""
_replace_component_manifest(domain, manifest)
def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None:
"""Keep the component's real ``to_code`` and add the given defines after it.
For host test and benchmark builds of code that is gated on a platform
define, such as loops that only exist on boards with PSRAM.
"""
from esphome import codegen as cg
manifest.enable_codegen()
original_to_code = manifest.to_code
async def to_code(config: ConfigType) -> None:
await original_to_code(config)
for define in defines:
cg.add_define(define)
if hasattr(original_to_code, "priority"):
to_code.priority = original_to_code.priority
manifest.to_code = to_code
@@ -0,0 +1,54 @@
"""Tests for ESP32 CAN bitrate validation."""
import pytest
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT
from esphome.components.esp32_can import canbus
import esphome.config_validation as cv
from esphome.core import CORE
import esphome.final_validate as fv
@pytest.mark.parametrize(
("revision", "supported"),
[
(None, False),
("0.0", False),
("1.0", False),
("1.1", False),
("2.0", True),
("3.0", True),
("3.1", True),
],
)
def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None:
"""Require the chip revision needed by the ESP-IDF timing macro."""
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"}
advanced = {} if revision is None else {"minimum_chip_revision": revision}
token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}})
try:
config = {"bit_rate": canbus.validate_bit_rate("20kbps")}
if supported:
canbus.FINAL_VALIDATE_SCHEMA(config)
else:
with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error:
canbus.FINAL_VALIDATE_SCHEMA(config)
assert error.value.path == ["bit_rate"]
finally:
fv.full_config.reset(token)
@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"])
def test_other_bitrates(variant: str) -> None:
"""Existing bitrates must not require a minimum chip revision."""
CORE.data[KEY_ESP32] = {KEY_VARIANT: variant}
config = {"bit_rate": canbus.validate_bit_rate("25kbps")}
canbus.FINAL_VALIDATE_SCHEMA(config)
@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"])
def test_other_variants_20kbps(variant: str) -> None:
"""Other supported ESP32 variants must retain 20 kbit/s support."""
CORE.data[KEY_ESP32] = {KEY_VARIANT: variant}
config = {"bit_rate": canbus.validate_bit_rate("20kbps")}
canbus.FINAL_VALIDATE_SCHEMA(config)