Merge branch 'dev' into esp32-log-v2-idf61

This commit is contained in:
J. Nick Koston
2026-10-14 16:30:23 -10:00
committed by GitHub
69 changed files with 2273 additions and 141 deletions
+1
View File
@@ -183,6 +183,7 @@ esphome/components/es7243e/* @kbx81
esphome/components/es8156/* @kbx81
esphome/components/es8311/* @kahrendt @kroimon
esphome/components/es8388/* @P4uLT
esphome/components/es9038q2m_katana/* @GioMez
esphome/components/esp32/* @esphome/core
esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito
esphome/components/esp32_ble_client/* @bdraco @jesserockz
+3
View File
@@ -403,6 +403,9 @@ message WizardCapabilities {
message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1;
// The Sendspin client_id: base64url of the client's static public key, 43 characters.
// Empty until the Sendspin client has started once since boot.
string client_id = 2 [(max_data_length) = 43];
}
message DeviceCapabilitiesResponse {
@@ -2131,6 +2131,7 @@ bool APIConnection::send_device_capabilities_response_() {
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id();
#endif
return this->send_message(resp);
}
+2
View File
@@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) {
uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags);
pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id);
return pos;
}
uint32_t SendspinCapabilities::calc_size_msg(const void *self) {
const auto &msg = *static_cast<const SendspinCapabilities *>(self);
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, msg.feature_flags);
size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0;
return size;
}
#endif
+1
View File
@@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage {
class SendspinCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef client_id{nullptr, 0}; // null until set, encode only
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+1
View File
@@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("client_id"), this->client_id);
return out.c_str();
}
#endif
@@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() {
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
@@ -118,13 +118,8 @@ void Ecocomfort2Fan::on_status() {
int new_speed = this->speed;
uint8_t base_speed = raw_speed & SPEED_MASK;
if (raw_speed & FLAG_BOOST) {
new_speed = SPEED_COUNT;
} else if (raw_speed & FLAG_NIGHT) {
new_speed = SPEED_SLEEP;
} else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) {
if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3)
new_speed = base_speed;
}
const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0);
const char *new_preset = preset != nullptr ? preset->name : nullptr;
bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset;
@@ -205,6 +205,10 @@ void Ecocomfort2Hub::publish_unknown_() {
sensor->publish_state(NAN);
}
#endif
#ifdef USE_BINARY_SENSOR
if (this->boost_binary_sensor_ != nullptr)
this->boost_binary_sensor_->invalidate_state();
#endif
}
// C_OPER: mode byte, speed/flags byte
@@ -216,10 +220,6 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) {
this->mode_ = data[0];
this->speed_ = data[1];
ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_);
#ifdef USE_BINARY_SENSOR
if (this->boost_binary_sensor_ != nullptr)
this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0);
#endif
#ifdef ECOCOMFORT2_CHILD_COUNT
for (auto *child : this->children_) {
child->on_status();
@@ -227,7 +227,9 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) {
#endif
}
// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction
// C_STATE: bytes 0-1 the mode and speed the unit runs; byte 1 also has the boost flag (0x40) and the advanced
// control step (0x20). Byte 2 the air flow direction, bytes 3-4 temperature and 5-6 humidity in hundredths,
// 7-8 VOC in ppb
void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) {
if (len < 9) {
ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len);
@@ -236,8 +238,12 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) {
const float temperature = static_cast<int16_t>(encode_uint16(data[3], data[4])) / 100.0f;
const float humidity = encode_uint16(data[5], data[6]) / 100.0f;
const uint16_t voc = encode_uint16(data[7], data[8]);
ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature,
humidity, voc, data[2]);
ESP_LOGD(TAG, "[%s] C_STATE: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u",
this->parent_->address_str(), data[0], data[1], temperature, humidity, voc, data[2]);
#ifdef USE_BINARY_SENSOR
if (this->boost_binary_sensor_ != nullptr)
this->boost_binary_sensor_->publish_state((data[1] & FLAG_BOOST) != 0);
#endif
#ifdef USE_SENSOR
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temperature);
@@ -267,7 +273,7 @@ void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) {
}
#ifdef USE_TIME
// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes
// C_CLOCK: two-digit year, month, day, weekday with Monday as 0, hour, minute, second, automatic DST flag
void Ecocomfort2Hub::sync_clock_() {
if (!this->is_ready() || this->char_handle_clock_ == 0)
return;
@@ -276,15 +282,11 @@ void Ecocomfort2Hub::sync_clock_() {
return;
// ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6
const uint8_t weekday = (now.day_of_week + 5) % 7;
uint8_t data[8]{now.hour,
now.minute,
now.second,
weekday,
now.day_of_month,
now.month,
static_cast<uint8_t>(now.year >> 8),
static_cast<uint8_t>(now.year)};
ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second);
// The time source already applies DST and every sync rewrites the clock, so the unit must not shift it again
uint8_t data[8]{
static_cast<uint8_t>(now.year % 100), now.month, now.day_of_month, weekday, now.hour, now.minute, now.second, 0};
ESP_LOGD(TAG, "[%s] Syncing clock to %04u-%02u-%02u %02u:%02u:%02u", this->parent_->address_str(), now.year,
now.month, now.day_of_month, now.hour, now.minute, now.second);
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_,
sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
@@ -32,7 +32,8 @@ static constexpr uint8_t OPER_OUT = 0x02;
static constexpr uint8_t OPER_IN_OUT = 0x03;
static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04;
// C_OPER byte 1: speed in the low nibble, flags in the high nibble
// Speed byte: speed in the low nibble, flags in the high nibble.
// C_OPER carries FLAG_AUTO; the unit reports FLAG_BOOST in byte 1 of C_STATE.
static constexpr uint8_t SPEED_OFF = 0x00;
static constexpr uint8_t SPEED_SLEEP = 0x01;
static constexpr uint8_t SPEED_VEL1 = 0x02;
@@ -41,7 +42,6 @@ static constexpr uint8_t SPEED_VEL3 = 0x04;
static constexpr uint8_t SPEED_MASK = 0x0F;
static constexpr uint8_t FLAG_AUTO = 0x10;
static constexpr uint8_t FLAG_BOOST = 0x40;
static constexpr uint8_t FLAG_NIGHT = 0x80;
/** Hub component for Ecocomfort 2.0 VMC via BLE. */
class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent {
@@ -0,0 +1,3 @@
CODEOWNERS = ["@GioMez"]
DEPENDENCIES = ["i2c"]
DOMAIN = "es9038q2m_katana"
@@ -0,0 +1,92 @@
import esphome.codegen as cg
from esphome.components import audio_dac, i2c
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE
from esphome.types import ConfigType
CONF_DEEMPHASIS = "deemphasis"
CONF_DOP = "dop"
CONF_FILTER_SHAPE = "filter_shape"
# Fields of the Katana format register: channel layout, word length and rate code.
FORMAT_STEREO = 0x80
BITS_PER_SAMPLE_CODES = {16: 0x10, 24: 0x20, 32: 0x30}
SAMPLE_RATE_CODES = {
44100: 0x04,
48000: 0x05,
88200: 0x06,
96000: 0x07,
176400: 0x08,
192000: 0x09,
352800: 0x0A,
384000: 0x0B,
}
es9038q2m_katana_ns = cg.esphome_ns.namespace("es9038q2m_katana")
ES9038Q2MKatana = es9038q2m_katana_ns.class_(
"ES9038Q2MKatana",
audio_dac.AudioDac,
cg.Component,
i2c.I2CDevice,
)
FilterShape = es9038q2m_katana_ns.enum("FilterShape")
FILTER_SHAPES = {
"linear_phase_fast": FilterShape.FILTER_SHAPE_LINEAR_PHASE_FAST,
"linear_phase_slow": FilterShape.FILTER_SHAPE_LINEAR_PHASE_SLOW,
"minimum_phase_fast": FilterShape.FILTER_SHAPE_MIN_PHASE_FAST,
"minimum_phase_slow": FilterShape.FILTER_SHAPE_MIN_PHASE_SLOW,
"apodizing": FilterShape.FILTER_SHAPE_APODIZING,
"hybrid": FilterShape.FILTER_SHAPE_HYBRID,
"brick_wall": FilterShape.FILTER_SHAPE_BRICK_WALL,
}
DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode")
DEEMPHASIS_MODES = {
"bypass": DeemphasisMode.DEEMPHASIS_BYPASS,
"32khz": DeemphasisMode.DEEMPHASIS_32KHZ,
"44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ,
"48khz": DeemphasisMode.DEEMPHASIS_48KHZ,
}
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ES9038Q2MKatana),
cv.Optional(CONF_FILTER_SHAPE, default="apodizing"): cv.enum(
FILTER_SHAPES, lower=True
),
cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All(
cv.float_with_unit("bits", "bit"),
cv.one_of(*BITS_PER_SAMPLE_CODES, int=True),
),
cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.one_of(
*SAMPLE_RATE_CODES, int=True
),
cv.Optional(CONF_DEEMPHASIS, default="bypass"): cv.enum(
DEEMPHASIS_MODES, lower=True
),
# Only switches the control logic; the source must already send DoP frames.
cv.Optional(CONF_DOP, default=False): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x30))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE]))
cg.add(
var.set_format(
FORMAT_STEREO
| BITS_PER_SAMPLE_CODES[config[CONF_BITS_PER_SAMPLE]]
| SAMPLE_RATE_CODES[config[CONF_SAMPLE_RATE]]
)
)
cg.add(var.set_deemphasis_mode(config[CONF_DEEMPHASIS]))
cg.add(var.set_dop_enabled(config[CONF_DOP]))
@@ -0,0 +1,176 @@
#include "es9038q2m_katana.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::es9038q2m_katana {
ESPHOME_LOG_TAG(TAG, "es9038q2m_katana");
namespace {
constexpr uint8_t KATANA_CHIP_ID = 0x30;
constexpr uint8_t KATANA_REG_CHIP_ID = 0x00;
constexpr uint8_t KATANA_REG_RESET = 0x01;
constexpr uint8_t KATANA_REG_VOLUME_1 = 0x02;
constexpr uint8_t KATANA_REG_VOLUME_2 = 0x03;
constexpr uint8_t KATANA_REG_MUTE = 0x04;
constexpr uint8_t KATANA_REG_DSP_PROGRAM = 0x05;
constexpr uint8_t KATANA_REG_DEEMPHASIS = 0x06;
constexpr uint8_t KATANA_REG_DOP = 0x07;
constexpr uint8_t KATANA_REG_FORMAT = 0x08;
constexpr uint8_t KATANA_REG_COMMAND = 0x09;
constexpr uint8_t KATANA_REG_MUTE_STREAM = 0x0A;
constexpr float KATANA_VOLUME_MIDPOINT = 0.5f;
constexpr float KATANA_VOLUME_MIDPOINT_STEPS = 50.0f; // -25 dB at 50% volume.
// Cubic amplitude gain: -20 * log10(volume^3) / 0.5 attenuation steps.
constexpr float KATANA_VOLUME_CUBIC_SCALE = 120.0f;
constexpr uint8_t KATANA_VOLUME_FULL_ATTENUATION = 255;
// The control logic ignores writes for this long after a reset.
constexpr uint32_t RESET_SETTLE_DELAY_MS = 10;
const LogString *filter_shape_to_str(FilterShape shape) {
switch (shape) {
case FILTER_SHAPE_LINEAR_PHASE_FAST:
return LOG_STR("linear_phase_fast");
case FILTER_SHAPE_LINEAR_PHASE_SLOW:
return LOG_STR("linear_phase_slow");
case FILTER_SHAPE_MIN_PHASE_FAST:
return LOG_STR("minimum_phase_fast");
case FILTER_SHAPE_MIN_PHASE_SLOW:
return LOG_STR("minimum_phase_slow");
case FILTER_SHAPE_APODIZING:
return LOG_STR("apodizing");
case FILTER_SHAPE_HYBRID:
return LOG_STR("hybrid");
default:
return LOG_STR("brick_wall");
}
}
const LogString *deemphasis_to_str(DeemphasisMode mode) {
switch (mode) {
case DEEMPHASIS_32KHZ:
return LOG_STR("32khz");
case DEEMPHASIS_44_1KHZ:
return LOG_STR("44_1khz");
case DEEMPHASIS_48KHZ:
return LOG_STR("48khz");
default:
return LOG_STR("bypass");
}
}
} // namespace
void ES9038Q2MKatana::setup() {
uint8_t chip_id = 0;
if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (chip_id != KATANA_CHIP_ID) {
ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id);
this->mark_failed(LOG_STR("Unexpected chip ID"));
return;
}
if (!this->write_byte(KATANA_REG_RESET, 0x01)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
delay(RESET_SETTLE_DELAY_MS);
// MUTE_STREAM is the gate the Linux driver closes between streams; ESPHome has no such
// notion, so it stays open and mute is only ever driven through MUTE.
if (!this->write_byte(KATANA_REG_FORMAT, this->format_) || !this->write_byte(KATANA_REG_COMMAND, 0x00) ||
!this->write_byte(KATANA_REG_DSP_PROGRAM, this->filter_shape_) ||
!this->write_byte(KATANA_REG_DEEMPHASIS, this->deemphasis_mode_) ||
!this->write_byte(KATANA_REG_DOP, this->dop_enabled_ ? 1 : 0) || !this->write_volume_() ||
!this->write_byte(KATANA_REG_MUTE, this->is_muted_ ? 1 : 0) || !this->write_byte(KATANA_REG_MUTE_STREAM, 0x00)) {
this->mark_failed(LOG_STR("Failed to configure the control logic"));
}
}
void ES9038Q2MKatana::dump_config() {
ESP_LOGCONFIG(TAG,
"ES9038Q2M Katana:\n"
" Filter shape: %s\n"
" De-emphasis: %s\n"
" Format register: 0x%02X\n"
" DoP: %s",
LOG_STR_ARG(filter_shape_to_str(this->filter_shape_)),
LOG_STR_ARG(deemphasis_to_str(this->deemphasis_mode_)), this->format_, ONOFF(this->dop_enabled_));
LOG_I2C_DEVICE(this);
if (this->is_failed())
return;
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
for (uint8_t reg = KATANA_REG_CHIP_ID; reg <= KATANA_REG_MUTE_STREAM; reg++) {
uint8_t value = 0;
if (this->read_byte(reg, &value)) {
ESP_LOGV(TAG, " Register 0x%02X: 0x%02X", reg, value);
} else {
ESP_LOGV(TAG, " Register 0x%02X: read failed", reg);
}
}
#endif
}
// Both channel registers follow ESPHome's single master volume.
bool ES9038Q2MKatana::write_volume_() {
return this->write_byte(KATANA_REG_VOLUME_1, this->volume_reg_) &&
this->write_byte(KATANA_REG_VOLUME_2, this->volume_reg_);
}
// Records the outcome of a runtime write; the cached state only changes on success.
bool ES9038Q2MKatana::check_write_(bool ok) {
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
}
return ok;
}
bool ES9038Q2MKatana::set_volume(float volume) {
if (this->is_failed())
return false;
volume = clamp(volume, 0.0f, 1.0f);
const uint8_t previous = this->volume_reg_;
float attenuation = KATANA_VOLUME_FULL_ATTENUATION;
if (volume >= KATANA_VOLUME_MIDPOINT) {
attenuation = KATANA_VOLUME_MIDPOINT_STEPS * (1.0f - volume) / (1.0f - KATANA_VOLUME_MIDPOINT);
} else if (volume > 0.0f) {
attenuation = KATANA_VOLUME_MIDPOINT_STEPS - KATANA_VOLUME_CUBIC_SCALE * log10f(volume / KATANA_VOLUME_MIDPOINT);
}
this->volume_reg_ =
static_cast<uint8_t>(lroundf(clamp(attenuation, 0.0f, static_cast<float>(KATANA_VOLUME_FULL_ATTENUATION))));
if (this->check_write_(this->write_volume_()))
return true;
this->volume_reg_ = previous;
return false;
}
float ES9038Q2MKatana::volume() {
if (this->volume_reg_ == KATANA_VOLUME_FULL_ATTENUATION)
return 0.0f;
if (this->volume_reg_ <= KATANA_VOLUME_MIDPOINT_STEPS)
return 1.0f - this->volume_reg_ * (1.0f - KATANA_VOLUME_MIDPOINT) / KATANA_VOLUME_MIDPOINT_STEPS;
return KATANA_VOLUME_MIDPOINT *
powf(10.0f, (KATANA_VOLUME_MIDPOINT_STEPS - this->volume_reg_) / KATANA_VOLUME_CUBIC_SCALE);
}
bool ES9038Q2MKatana::set_mute_state_(bool mute_state) {
if (this->is_failed())
return false;
if (!this->check_write_(this->write_byte(KATANA_REG_MUTE, mute_state ? 1 : 0)))
return false;
this->is_muted_ = mute_state;
return true;
}
} // namespace esphome::es9038q2m_katana
@@ -0,0 +1,62 @@
#pragma once
#include "esphome/components/audio_dac/audio_dac.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::es9038q2m_katana {
// DSP program numbers of the Katana control interface; there is no program 5.
enum FilterShape : uint8_t {
FILTER_SHAPE_LINEAR_PHASE_FAST = 0,
FILTER_SHAPE_LINEAR_PHASE_SLOW = 1,
FILTER_SHAPE_MIN_PHASE_FAST = 2,
FILTER_SHAPE_MIN_PHASE_SLOW = 3,
FILTER_SHAPE_APODIZING = 4,
FILTER_SHAPE_HYBRID = 6,
FILTER_SHAPE_BRICK_WALL = 7,
};
enum DeemphasisMode : uint8_t {
DEEMPHASIS_BYPASS = 0,
DEEMPHASIS_32KHZ = 1,
DEEMPHASIS_44_1KHZ = 2,
DEEMPHASIS_48KHZ = 3,
};
// Boards that expose an ES9038Q2M through the Allo Katana control logic at I2C address 0x30.
// Ported from the Linux driver sound/soc/bcm/allo-katana-codec.c; the DAC's own registers
// are never written, the control logic applies the configuration itself.
class ES9038Q2MKatana : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
// Cubic below 50%, then 0.5 dB per 1% above, meeting at -25 dB.
// Zero selects full attenuation (-127.5 dB).
bool set_volume(float volume) override;
float volume() override;
bool set_mute_off() override { return this->set_mute_state_(false); }
bool set_mute_on() override { return this->set_mute_state_(true); }
bool is_muted() override { return this->is_muted_; }
void set_filter_shape(FilterShape shape) { this->filter_shape_ = shape; }
void set_format(uint8_t format) { this->format_ = format; }
void set_deemphasis_mode(DeemphasisMode mode) { this->deemphasis_mode_ = mode; }
void set_dop_enabled(bool enabled) { this->dop_enabled_ = enabled; }
protected:
bool set_mute_state_(bool mute_state);
bool write_volume_();
bool check_write_(bool ok);
FilterShape filter_shape_{FILTER_SHAPE_APODIZING};
DeemphasisMode deemphasis_mode_{DEEMPHASIS_BYPASS};
uint8_t format_{0};
// Attenuation in 0.5 dB steps: 0 is loudest, 255 quietest. Starts 20 dB down.
uint8_t volume_reg_{0x28};
bool dop_enabled_{false};
};
} // namespace esphome::es9038q2m_katana
@@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) {
esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
esp_ble_conn_update_params_t conn_params = {};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
+38
View File
@@ -1,2 +1,40 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN
from esphome.types import ConfigType
CODEOWNERS = ["@remcom"]
DEPENDENCIES = ["i2c"]
DOMAIN = "fusb302b"
CONF_REQUEST_VOLTAGE = "request_voltage"
fusb302b_ns = cg.esphome_ns.namespace("fusb302b")
FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice)
# USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the
# requested voltage is chosen.
validate_voltage = cv.int_range(min=5, max=20)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(FUSB302B),
cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema,
cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x22)),
cv.only_on_esp32,
)
async def to_code(config: ConfigType) -> None:
interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN])
var = cg.new_Pvariable(config[CONF_ID], interrupt_pin)
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE]))
+415
View File
@@ -0,0 +1,415 @@
#include "fusb302b.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include "fusb302b_registers.h"
namespace esphome::fusb302b {
ESPHOME_LOG_TAG(TAG, "fusb302b");
// Task notification bits
static constexpr uint32_t NOTIFY_IRQ = 1 << 0;
static constexpr uint32_t NOTIFY_STOP = 1 << 1;
static constexpr uint32_t TASK_STACK_SIZE = 4096;
static constexpr UBaseType_t TASK_PRIORITY = 18;
static constexpr uint32_t STARTUP_DELAY_MS = 2000;
static constexpr uint32_t CC_SETTLE_MS = 5;
static constexpr uint32_t CC_POLL_INTERVAL_MS = 500;
static constexpr uint32_t PD_POLL_INTERVAL_MS = 100;
// Safety net in case an interrupt edge is missed while attached and idle
static constexpr uint32_t IDLE_POLL_INTERVAL_MS = 1000;
// Time to wait for the source to send its capabilities on its own after attach
static constexpr uint32_t SOURCE_CAP_WAIT_MS = 5000;
// Time to wait after each GET_SOURCE_CAP / soft reset before the next recovery step
static constexpr uint32_t SOURCE_CAP_RETRY_MS = 2500;
// Upper bound on back-to-back interrupt handling while INT_N stays asserted
static constexpr uint8_t MAX_IRQ_ROUNDS = 8;
// BC_LVL is read this many extra times to confirm a stable measurement
static constexpr uint8_t CC_STABLE_READS = 5;
static constexpr uint8_t CC_LEVEL_UNSTABLE = 0xFF;
void IRAM_ATTR FUSB302B::gpio_intr(FUSB302B *arg) {
TaskHandle_t handle = arg->task_.get_handle();
if (handle == nullptr)
return;
BaseType_t woken = pdFALSE;
xTaskNotifyFromISR(handle, NOTIFY_IRQ, eSetBits, &woken);
portYIELD_FROM_ISR(woken);
}
void FUSB302B::task_func(void *arg) { static_cast<FUSB302B *>(arg)->run_task_(); }
void FUSB302B::setup() {
uint8_t device_id = 0;
if (!this->read_byte(FUSB_DEVICE_ID, &device_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
const uint8_t version = device_id & FUSB_DEVICE_ID_VERSION_MASK;
if (version != FUSB_DEVICE_ID_VERSION_A && version != FUSB_DEVICE_ID_VERSION_B) {
ESP_LOGE(TAG, "Unknown device ID 0x%02X", device_id);
this->mark_failed();
return;
}
ESP_LOGD(TAG, "Device ID 0x%02X (product %u, revision %u)", device_id, (device_id >> 2) & 0x3, device_id & 0x3);
if (!this->init_chip_()) {
this->mark_failed(LOG_STR("Initialization failed"));
return;
}
this->interrupt_pin_->setup();
// The task must exist before the interrupt is attached, since the ISR notifies it
if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) {
this->mark_failed(LOG_STR("Failed to create task"));
return;
}
this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE);
}
void FUSB302B::dump_config() {
ESP_LOGCONFIG(TAG,
"FUSB302B:\n"
" Request Voltage: %u V",
this->get_request_voltage());
LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
}
void FUSB302B::on_shutdown() {
this->interrupt_pin_->detach_interrupt();
// The task parks itself instead of being deleted, so it can never be stopped in the middle of an I2C transfer
if (this->task_.is_created())
xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits);
}
void FUSB302B::run_task_() {
// Give the source time to settle after power-up before the first CC measurement
vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS));
while (true) {
uint32_t wait_ms = CC_POLL_INTERVAL_MS;
if (this->attached_) {
const bool busy = this->waiting_for_source_caps_ || this->retry_request_pending_ || this->check_ams_();
wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
}
uint32_t bits = 0;
xTaskNotifyWait(0, UINT32_MAX, &bits, pdMS_TO_TICKS(wait_ms));
if (bits & NOTIFY_STOP) {
while (true)
vTaskSuspend(nullptr);
}
// INT_N is held low until the interrupt registers are read, so a new event that arrives while it is
// already low has no falling edge: keep servicing while the pin stays asserted.
if (bits & NOTIFY_IRQ)
this->handle_interrupt_();
for (uint8_t i = 0; i < MAX_IRQ_ROUNDS && !this->interrupt_pin_->digital_read(); i++)
this->handle_interrupt_();
if (!this->attached_) {
this->try_attach_();
continue;
}
this->check_transition_timeout_();
this->check_request_retry_();
this->check_source_caps_();
}
}
bool FUSB302B::init_chip_() {
// Software reset restores all registers to their defaults
if (!this->write_byte(FUSB_RESET, FUSB_RESET_SW_RES))
return false;
if (!this->reset_pd_())
return false;
// Unmask the interrupts used for PD messaging and VBUS changes
if (!this->write_byte(FUSB_MASK1, FUSB_MASK1_DEFAULT) || !this->write_byte(FUSB_MASKA, 0) ||
!this->write_byte(FUSB_MASKB, 0))
return false;
// Let the chip retransmit unacknowledged messages up to 3 times
uint8_t control3 = 0;
if (!this->read_byte(FUSB_CONTROL3, &control3))
return false;
control3 =
(control3 & ~FUSB_CONTROL3_N_RETRIES_MASK) | (3 << FUSB_CONTROL3_N_RETRIES_SHIFT) | FUSB_CONTROL3_AUTO_RETRY;
if (!this->write_byte(FUSB_CONTROL3, control3))
return false;
if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL))
return false;
return this->reset_pd_();
}
bool FUSB302B::reset_pd_() {
this->reset_protocol_();
// Writing CONTROL0 as a whole byte also clears INT_MASK, which is what enables the INT_N pin
return this->write_byte(FUSB_CONTROL0, FUSB_CONTROL0_TX_FLUSH) &&
this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH) && this->write_byte(FUSB_RESET, FUSB_RESET_PD_RESET);
}
bool FUSB302B::enable_auto_crc_() {
uint8_t switches1 = 0;
return this->read_byte(FUSB_SWITCHES1, &switches1) &&
this->write_byte(FUSB_SWITCHES1, switches1 | FUSB_SWITCHES1_AUTO_CRC);
}
void FUSB302B::handle_interrupt_() {
// STATUS0A, STATUS1A, INTERRUPTA, INTERRUPTB, STATUS0, STATUS1, INTERRUPT; reading clears the interrupts
uint8_t regs[7];
if (this->read_register(FUSB_STATUS0A, regs, sizeof(regs)) != i2c::ERROR_OK) {
this->enter_error_();
return;
}
const uint8_t interrupta = regs[2];
const uint8_t status0 = regs[4];
uint8_t status1 = regs[5];
const uint8_t interrupt = regs[6];
if ((interrupt & FUSB_INTERRUPT_I_VBUSOK) && !(status0 & FUSB_STATUS0_VBUSOK)) {
if (this->attached_)
this->detach_();
return;
}
if (interrupta & FUSB_INTERRUPTA_I_HARDRST) {
if (this->attached_)
this->handle_hard_reset_();
return;
}
if (interrupta & FUSB_INTERRUPTA_I_SOFTRST) {
ESP_LOGV(TAG, "Soft reset received");
}
if ((interrupta & FUSB_INTERRUPTA_I_RETRYFAIL) && this->attached_)
this->handle_transmit_failed_();
if (!this->attached_)
return;
PdMsg msg;
while (!(status1 & FUSB_STATUS1_RX_EMPTY)) {
bool is_sop = false;
if (!this->read_message_(msg, is_sop)) {
ESP_LOGW(TAG, "Reading message failed");
// A partial read leaves the FIFO misaligned
this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH);
return;
}
// Only SOP packets are addressed to a sink; SOP'/SOP'' are for cable plugs
if (is_sop)
this->handle_message_(msg);
if (!this->read_byte(FUSB_STATUS1, &status1))
return;
}
}
bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) {
uint8_t token = 0;
uint8_t header[2];
if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK)
return false;
msg.set_header(encode_uint16(header[1], header[0]));
// Data objects, followed by the CRC32 that the chip already checked
uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4];
if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK)
return false;
for (uint8_t i = 0; i < msg.num_of_obj; i++) {
const uint8_t *obj = &buf[i * 4];
msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]);
}
is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP;
return true;
}
bool FUSB302B::send_message(const PdMsg &msg) {
// Register address, then the TX tokens; written in one transfer without the copy write_register() makes
uint8_t buf[1 + 4 + 1 + 2 + PD_MAX_NUM_DATA_OBJECTS * 4 + 4];
size_t len = 0;
const uint16_t header = msg.get_coded_header();
buf[len++] = FUSB_FIFOS;
buf[len++] = FUSB_TX_TOKEN_SYNC1;
buf[len++] = FUSB_TX_TOKEN_SYNC1;
buf[len++] = FUSB_TX_TOKEN_SYNC1;
buf[len++] = FUSB_TX_TOKEN_SYNC2;
buf[len++] = FUSB_TX_TOKEN_PACKSYM | (msg.num_of_obj * 4 + 2);
buf[len++] = header & 0xFF;
buf[len++] = header >> 8;
for (uint8_t i = 0; i < msg.num_of_obj; i++) {
const uint32_t obj = msg.data_objects[i];
buf[len++] = obj & 0xFF;
buf[len++] = (obj >> 8) & 0xFF;
buf[len++] = (obj >> 16) & 0xFF;
buf[len++] = obj >> 24;
}
buf[len++] = FUSB_TX_TOKEN_JAM_CRC;
buf[len++] = FUSB_TX_TOKEN_EOP;
buf[len++] = FUSB_TX_TOKEN_TXOFF;
buf[len++] = FUSB_TX_TOKEN_TXON;
i2c::ErrorCode err = this->write(buf, len);
if (err != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Sending message type %u failed (error %d)", msg.type, static_cast<int>(err));
return false;
}
return true;
}
bool FUSB302B::measure_cc_(uint8_t meas_switch, uint8_t &level) {
if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch))
return false;
vTaskDelay(pdMS_TO_TICKS(CC_SETTLE_MS));
uint8_t status0 = 0;
if (!this->read_byte(FUSB_STATUS0, &status0))
return false;
level = status0 & FUSB_STATUS0_BC_LVL;
for (uint8_t i = 0; i < CC_STABLE_READS; i++) {
if (!this->read_byte(FUSB_STATUS0, &status0))
return false;
if ((status0 & FUSB_STATUS0_BC_LVL) != level) {
level = CC_LEVEL_UNSTABLE;
return true;
}
}
return true;
}
void FUSB302B::try_attach_() {
if (this->state_ == PdState::PD_STATE_ERROR) {
if (!this->init_chip_())
return;
ESP_LOGI(TAG, "Communication restored");
this->set_state_(PdState::PD_STATE_DISCONNECTED);
}
uint8_t status0 = 0;
if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL) || !this->read_byte(FUSB_STATUS0, &status0)) {
this->enter_error_();
return;
}
// A source may present its CC pull-up before VBUS is on; detach is only detected when VBUS drops
if (!(status0 & FUSB_STATUS0_VBUSOK)) {
this->cc_ambiguous_logged_ = false;
return;
}
uint8_t cc1 = 0;
uint8_t cc2 = 0;
if (!this->write_byte(FUSB_SWITCHES1, FUSB_SWITCHES1_SPECREV_REV2) ||
!this->write_byte(FUSB_MEASURE, FUSB_MEASURE_MDAC_CC) || !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC1, cc1) ||
!this->measure_cc_(FUSB_SWITCHES0_MEAS_CC2, cc2)) {
this->enter_error_();
return;
}
// Exactly one CC line carries the source pull-up; the other one is open
uint8_t meas_switch;
uint8_t tx_switch;
if (cc1 != CC_LEVEL_UNSTABLE && cc1 > 0 && cc2 == 0) {
meas_switch = FUSB_SWITCHES0_MEAS_CC1;
tx_switch = FUSB_SWITCHES1_TXCC1;
} else if (cc2 != CC_LEVEL_UNSTABLE && cc2 > 0 && cc1 == 0) {
meas_switch = FUSB_SWITCHES0_MEAS_CC2;
tx_switch = FUSB_SWITCHES1_TXCC2;
} else {
if (!this->cc_ambiguous_logged_) {
ESP_LOGD(TAG, "VBUS present but no single CC line with a source pull-up (CC1 %u, CC2 %u)", cc1, cc2);
this->cc_ambiguous_logged_ = true;
}
return;
}
if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch) ||
!this->write_byte(FUSB_SWITCHES1, tx_switch | FUSB_SWITCHES1_SPECREV_REV2) || !this->enable_auto_crc_() ||
!this->reset_pd_()) {
this->enter_error_();
return;
}
ESP_LOGD(TAG, "Attached on CC%u", meas_switch == FUSB_SWITCHES0_MEAS_CC1 ? 1 : 2);
this->attached_ = true;
this->cc_ambiguous_logged_ = false;
this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT);
this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT);
}
void FUSB302B::detach_() {
ESP_LOGD(TAG, "Detached (VBUS lost)");
this->attached_ = false;
this->waiting_for_source_caps_ = false;
this->reset_pd_();
this->clear_contract_();
this->set_state_(PdState::PD_STATE_DISCONNECTED);
}
void FUSB302B::handle_hard_reset_() {
ESP_LOGD(TAG, "Hard reset received");
// The source returns to Type-C default power and sends its capabilities again
// Also flushes the RX FIFO, so the caller has nothing left to read
if (!this->reset_pd_()) {
this->enter_error_();
return;
}
this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT);
this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT);
}
void FUSB302B::enter_error_() {
if (this->state_ == PdState::PD_STATE_ERROR)
return;
ESP_LOGW(TAG, "Communication with the chip failed");
this->attached_ = false;
this->waiting_for_source_caps_ = false;
this->clear_contract_();
this->set_state_(PdState::PD_STATE_ERROR);
}
void FUSB302B::send_soft_reset_() {
this->reset_pd_();
this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET));
// The source answers a soft reset with new source capabilities
this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT);
}
void FUSB302B::start_source_cap_wait_(SourceCapStep step) {
this->waiting_for_source_caps_ = true;
this->source_cap_step_ = step;
this->source_cap_step_start_ = millis();
}
void FUSB302B::check_source_caps_() {
if (!this->waiting_for_source_caps_ || this->check_ams_())
return;
const uint32_t elapsed = millis() - this->source_cap_step_start_;
switch (this->source_cap_step_) {
case SourceCapStep::SOURCE_CAP_STEP_WAIT:
if (elapsed < SOURCE_CAP_WAIT_MS)
return;
ESP_LOGD(TAG, "No source capabilities received, requesting them");
this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP));
this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT;
break;
case SourceCapStep::SOURCE_CAP_STEP_GET_SENT:
if (elapsed < SOURCE_CAP_RETRY_MS)
return;
ESP_LOGD(TAG, "No source capabilities received, sending soft reset");
this->send_soft_reset_();
return;
case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT:
if (elapsed < SOURCE_CAP_RETRY_MS)
return;
ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V");
this->waiting_for_source_caps_ = false;
this->set_state_(PdState::PD_STATE_PD_TIMEOUT);
return;
}
this->source_cap_step_start_ = millis();
}
} // namespace esphome::fusb302b
#endif // USE_ESP32
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/static_task.h"
#include "pd.h"
namespace esphome::fusb302b {
/// FUSB302B USB Type-C / Power Delivery sink controller.
///
/// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing
/// even while the main loop is busy.
class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice {
public:
explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {}
void setup() override;
void dump_config() override;
void on_shutdown() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
protected:
enum class SourceCapStep : uint8_t {
SOURCE_CAP_STEP_WAIT,
SOURCE_CAP_STEP_GET_SENT,
SOURCE_CAP_STEP_SOFT_RESET_SENT,
};
static void gpio_intr(FUSB302B *arg);
static void task_func(void *arg);
bool send_message(const PdMsg &msg) override;
void on_state_changed() override {}
void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); }
void on_source_caps_received() override { this->waiting_for_source_caps_ = false; }
void on_transition_timeout() override { this->send_soft_reset_(); }
// PHY task only
[[noreturn]] void run_task_();
bool init_chip_();
bool reset_pd_();
bool enable_auto_crc_();
void handle_interrupt_();
bool read_message_(PdMsg &msg, bool &is_sop);
void try_attach_();
bool measure_cc_(uint8_t meas_switch, uint8_t &level);
void detach_();
void handle_hard_reset_();
void start_source_cap_wait_(SourceCapStep step);
void send_soft_reset_();
void check_source_caps_();
void enter_error_();
InternalGPIOPin *interrupt_pin_;
StaticTask task_;
// PHY task only
uint32_t source_cap_step_start_{0};
SourceCapStep source_cap_step_{SourceCapStep::SOURCE_CAP_STEP_WAIT};
bool attached_{false};
bool waiting_for_source_caps_{false};
bool cc_ambiguous_logged_{false};
};
} // namespace esphome::fusb302b
#endif // USE_ESP32
@@ -0,0 +1,94 @@
#pragma once
#include <cstdint>
namespace esphome::fusb302b {
// Register addresses
static constexpr uint8_t FUSB_DEVICE_ID = 0x01;
static constexpr uint8_t FUSB_SWITCHES0 = 0x02;
static constexpr uint8_t FUSB_SWITCHES1 = 0x03;
static constexpr uint8_t FUSB_MEASURE = 0x04;
static constexpr uint8_t FUSB_CONTROL0 = 0x06;
static constexpr uint8_t FUSB_CONTROL1 = 0x07;
static constexpr uint8_t FUSB_CONTROL3 = 0x09;
static constexpr uint8_t FUSB_MASK1 = 0x0A;
static constexpr uint8_t FUSB_POWER = 0x0B;
static constexpr uint8_t FUSB_RESET = 0x0C;
static constexpr uint8_t FUSB_MASKA = 0x0E;
static constexpr uint8_t FUSB_MASKB = 0x0F;
static constexpr uint8_t FUSB_STATUS0A = 0x3C;
static constexpr uint8_t FUSB_STATUS0 = 0x40;
static constexpr uint8_t FUSB_STATUS1 = 0x41;
static constexpr uint8_t FUSB_FIFOS = 0x43;
// DEVICE_ID: version in bits 7:4, product variant (I2C address 0x22-0x25) in bits 3:2, silicon revision in bits 1:0
static constexpr uint8_t FUSB_DEVICE_ID_VERSION_MASK = 0xF0;
static constexpr uint8_t FUSB_DEVICE_ID_VERSION_A = 0x80;
static constexpr uint8_t FUSB_DEVICE_ID_VERSION_B = 0x90;
// SWITCHES0 bits
static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC2 = 1 << 3;
static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC1 = 1 << 2;
static constexpr uint8_t FUSB_SWITCHES0_PDWN_2 = 1 << 1;
static constexpr uint8_t FUSB_SWITCHES0_PDWN_1 = 1 << 0;
// SWITCHES1 bits
static constexpr uint8_t FUSB_SWITCHES1_SPECREV_REV2 = 0x01 << 5;
static constexpr uint8_t FUSB_SWITCHES1_AUTO_CRC = 1 << 2;
static constexpr uint8_t FUSB_SWITCHES1_TXCC2 = 1 << 1;
static constexpr uint8_t FUSB_SWITCHES1_TXCC1 = 1 << 0;
// MEASURE value used for CC detection
static constexpr uint8_t FUSB_MEASURE_MDAC_CC = 49;
// CONTROL0 bits
static constexpr uint8_t FUSB_CONTROL0_TX_FLUSH = 1 << 6;
// CONTROL1 bits
static constexpr uint8_t FUSB_CONTROL1_RX_FLUSH = 1 << 2;
// CONTROL3 bits
static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_SHIFT = 1;
static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_MASK = 0x3 << FUSB_CONTROL3_N_RETRIES_SHIFT;
static constexpr uint8_t FUSB_CONTROL3_AUTO_RETRY = 1 << 0;
// MASK1: mask the ACTIVITY, CRC_CHK and BC_LVL interrupts
static constexpr uint8_t FUSB_MASK1_DEFAULT = 0x51;
// POWER bits
static constexpr uint8_t FUSB_POWER_ALL = 0x0F;
// RESET bits
static constexpr uint8_t FUSB_RESET_PD_RESET = 1 << 1;
static constexpr uint8_t FUSB_RESET_SW_RES = 1 << 0;
// INTERRUPTA bits
static constexpr uint8_t FUSB_INTERRUPTA_I_RETRYFAIL = 1 << 4;
static constexpr uint8_t FUSB_INTERRUPTA_I_SOFTRST = 1 << 1;
static constexpr uint8_t FUSB_INTERRUPTA_I_HARDRST = 1 << 0;
// INTERRUPT bits
static constexpr uint8_t FUSB_INTERRUPT_I_VBUSOK = 1 << 7;
// STATUS0 bits
static constexpr uint8_t FUSB_STATUS0_VBUSOK = 1 << 7;
static constexpr uint8_t FUSB_STATUS0_BC_LVL = 0x03;
// STATUS1 bits
static constexpr uint8_t FUSB_STATUS1_RX_EMPTY = 1 << 5;
// RX FIFO token
static constexpr uint8_t FUSB_FIFO_RX_TOKEN_BITS = 0xE0;
static constexpr uint8_t FUSB_FIFO_RX_SOP = 0xE0;
// TX FIFO tokens
static constexpr uint8_t FUSB_TX_TOKEN_TXON = 0xA1;
static constexpr uint8_t FUSB_TX_TOKEN_SYNC1 = 0x12;
static constexpr uint8_t FUSB_TX_TOKEN_SYNC2 = 0x13;
static constexpr uint8_t FUSB_TX_TOKEN_PACKSYM = 0x80;
static constexpr uint8_t FUSB_TX_TOKEN_JAM_CRC = 0xFF;
static constexpr uint8_t FUSB_TX_TOKEN_EOP = 0x14;
static constexpr uint8_t FUSB_TX_TOKEN_TXOFF = 0xFE;
} // namespace esphome::fusb302b
+1
View File
@@ -30,6 +30,7 @@ enum PdControlMsgType : uint8_t {
PD_CNTRL_REJECT = 0x04,
PD_CNTRL_PING = 0x05,
PD_CNTRL_PS_RDY = 0x06,
PD_CNTRL_GET_SOURCE_CAP = 0x07,
PD_CNTRL_GET_SINK_CAP = 0x08,
PD_CNTRL_WAIT = 0x0C,
PD_CNTRL_SOFT_RESET = 0x0D,
@@ -13,6 +13,7 @@ from esphome.components.http_request import validate_url
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_FRAMEWORK,
CONF_ID,
CONF_INTERNAL,
CONF_MICROPHONE,
@@ -26,10 +27,12 @@ from esphome.const import (
CONF_TYPE,
CONF_URL,
CONF_USERNAME,
PLATFORM_ESP32,
TYPE_GIT,
TYPE_LOCAL,
)
from esphome.core import CORE, HexInt
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -41,6 +44,8 @@ DEPENDENCIES = ["microphone"]
DOMAIN = "micro_wake_word"
CONF_TASK_CORE = "task_core"
CONF_FEATURE_STEP_SIZE = "feature_step_size"
CONF_MODELS = "models"
CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected"
@@ -407,6 +412,27 @@ def _download_http_models(config: ConfigType) -> ConfigType:
return config
def _validate_task_core(config: ConfigType) -> ConfigType:
if config[CONF_TASK_CORE] != 1:
return config
esp32.only_on_variant(
supported=[
esp32.VARIANT_ESP32,
esp32.VARIANT_ESP32S3,
esp32.VARIANT_ESP32P4,
esp32.VARIANT_ESP32H4,
esp32.VARIANT_ESP32S31,
],
msg_prefix="micro_wake_word task_core: 1",
)(config)
sdkconfig = fv.full_config.get()[PLATFORM_ESP32][CONF_FRAMEWORK][
esp32.CONF_SDKCONFIG_OPTIONS
]
if sdkconfig.get("CONFIG_FREERTOS_UNICORE") == "y":
raise cv.Invalid("task_core: 1 requires FreeRTOS dual-core mode")
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -427,6 +453,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_VAD): _maybe_empty_vad_schema,
cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean,
cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1),
cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_MODEL): cv.invalid(
@@ -509,6 +536,7 @@ FINAL_VALIDATE_SCHEMA = cv.All(
},
extra=cv.ALLOW_EXTRA,
),
_validate_task_core,
_feature_step_size_validate,
)
@@ -521,6 +549,12 @@ async def to_code(config):
cg.add(var.set_microphone_source(mic_source))
cg.add_define("USE_MICRO_WAKE_WORD")
cg.add_define(
"MICRO_WAKE_WORD_TASK_CORE",
cg.RawExpression("tskNO_AFFINITY")
if config[CONF_TASK_CORE] == -1
else config[CONF_TASK_CORE],
)
if config[CONF_PREFER_INTERNAL_RAM]:
cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM")
ota.request_ota_state_listeners()
@@ -17,6 +17,10 @@
namespace esphome::micro_wake_word {
// Config validation checks the YAML, this checks the build: a unicore sdkconfig reached any other way
static_assert(MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY || MICRO_WAKE_WORD_TASK_CORE < portNUM_PROCESSORS,
"micro_wake_word task_core is not a core of this build");
ESPHOME_LOG_TAG(TAG, "micro_wake_word");
static const ssize_t DETECTION_QUEUE_LENGTH = 5;
@@ -84,6 +88,12 @@ static const LogString *micro_wake_word_state_to_string(State state) {
void MicroWakeWord::dump_config() {
ESP_LOGCONFIG(TAG, "microWakeWord:");
// tskNO_AFFINITY is a cast expression, so this cannot be a preprocessor test; the compiler folds it
if (MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY) {
ESP_LOGCONFIG(TAG, " Inference task core: any");
} else {
ESP_LOGCONFIG(TAG, " Inference task core: %d", MICRO_WAKE_WORD_TASK_CORE);
}
ESP_LOGCONFIG(TAG, " models:");
for (auto &model : this->wake_word_models_) {
model->log_model_config();
@@ -477,7 +487,8 @@ void MicroWakeWord::loop() {
}
if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE,
(void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) {
(void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_,
MICRO_WAKE_WORD_TASK_CORE)) {
FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state
this->status_momentary_error(1000);
}
@@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip):
def transforms(self) -> set[str]:
"""
Return the set of transformations supported by this driver chip.
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY.
A model with an empty init sequence has no command interface at all, so it
cannot apply any transform in hardware.
"""
if self.initsequence is not None and len(self.initsequence) == 0:
return set()
return super().transforms - {CONF_SWAP_XY}
+29 -4
View File
@@ -27,7 +27,8 @@ _LOGGER = logging.getLogger(__name__)
# High performance networking tracking infrastructure
# Components can request high performance networking and this configures lwip and WiFi settings
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" # lwip tier
KEY_HIGH_PERFORMANCE_WIFI = "high_performance_wifi" # WiFi driver buffers
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
CONF_TCP_SEND_BUFFER = "tcp_send_buffer"
@@ -225,9 +226,26 @@ def require_high_performance_networking() -> None:
_request_high_performance_networking,
)
"""
# Only set up once (idempotent - multiple components can call this)
if not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False):
CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True
CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True
require_high_performance_wifi()
def require_high_performance_wifi() -> None:
"""Request only the WiFi driver part of high performance networking.
For traffic that passes through the device below its own sockets, such as the
NAT of a WISP access point: more RX/TX buffers, AMPDU aggregation and a deeper
lwip input mailbox help every forwarded packet, while the larger TCP windows
would only serve the device's own connections and cost internal RAM for nothing.
``network: enable_high_performance: false`` turns the mailbox part off like the
lwip tier; the WiFi driver settings follow the component request alone.
"""
CORE.data[KEY_HIGH_PERFORMANCE_WIFI] = True
def has_high_performance_wifi() -> bool:
"""True when a component asked for the high performance WiFi driver settings."""
return CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI, False)
def has_high_performance_networking() -> bool:
@@ -519,6 +537,13 @@ async def to_code(config: ConfigType) -> None:
add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window)
add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64)
add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox)
elif (
CORE.is_esp32 and enable_high_perf is not False and has_high_performance_wifi()
):
# Forwarded frames still queue through tcpip_input, so the WiFi-only tier deepens that mailbox too
add_idf_sdkconfig_option(
"CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", TCPIP_RECVMBOX_OPTIMIZED
)
# After the high performance block so an explicit size wins over the
# bundle's 65534 (last write wins in the sdkconfig store).
@@ -58,6 +58,9 @@ void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context)
// Gets the off-mesh routable address
std::optional<otIp6Address> OpenThreadComponent::get_omr_address() {
// A failed setup never creates the lock, and acquire() would abort waiting for it
if (this->is_failed())
return {};
InstanceLock lock = InstanceLock::acquire();
return this->get_omr_address_(lock);
}
@@ -122,6 +125,10 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp
}
void OpenThreadSrpComponent::setup() {
if (global_openthread_component->is_failed()) {
this->mark_failed();
return;
}
otError error;
InstanceLock lock = InstanceLock::acquire();
otInstance *instance = lock.get_instance();
@@ -228,6 +235,9 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) {
}
bool OpenThreadComponent::teardown() {
// Nothing was started
if (this->is_failed())
return true;
switch (this->teardown_stage_) {
case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: {
{
@@ -272,6 +282,11 @@ bool OpenThreadComponent::teardown() {
}
void OpenThreadComponent::on_factory_reset(std::function<void()> callback) {
if (this->is_failed()) {
// No SRP registration to remove; reset right away
callback();
return;
}
this->factory_reset_external_callback_ = std::move(callback);
ESP_LOGD(TAG, "Start Removal SRP Host and Services");
otError error;
@@ -10,12 +10,12 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
#include "esp_err.h"
#include "esp_netif.h"
#include "esp_openthread_netif_glue.h"
#include "esp_vfs_eventfd.h"
#include "nvs_flash.h"
ESPHOME_LOG_TAG(TAG, "openthread");
@@ -29,8 +29,14 @@ void OpenThreadComponent::setup() {
esp_vfs_eventfd_config_t eventfd_config = {
.max_fds = 3,
};
// Network interface setup handled by network component
ESP_ERROR_CHECK(nvs_flash_init());
// Network interface setup handled by network component.
// Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start()
// aborts without it, so fail this component instead.
if (global_preferences->nvs_handle == 0) {
ESP_LOGE(TAG, "NVS unavailable");
this->mark_failed();
return;
}
ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config));
esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(),
@@ -153,6 +159,11 @@ InstanceLock InstanceLock::acquire() {
ESP_LOGE(TAG, "OpenThread component torn down, cannot acquire instance lock");
abort();
}
// A failed setup() never creates the lock; say so now instead of after the wait below
if (global_openthread_component->is_failed()) {
ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock");
abort();
}
// Wait for the lock to be created before attempting to acquire it.
// esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start().
constexpr uint32_t lock_init_timeout_ms = 10000;
@@ -243,6 +243,14 @@ std::optional<std::string> SendspinHub::get_pairing_token() const {
return this->client_->pairing_token();
}
// THREAD CONTEXT: Main loop
StringRef SendspinHub::get_client_id() const {
if (this->client_ == nullptr) {
return StringRef();
}
return StringRef(this->client_->client_id());
}
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
void SendspinHub::set_unpaired_access_enabled(bool enabled) {
this->unpaired_access_ = enabled;
@@ -198,6 +198,12 @@ class SendspinHub final : public Component,
/// started once; it stays available after the client stops. Main loop only.
std::optional<std::string> get_pairing_token() const;
/// @brief Returns the Sendspin client_id: base64url of the client's static public key.
///
/// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into
/// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only.
StringRef get_client_id() const;
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
+153 -7
View File
@@ -18,8 +18,9 @@ from esphome.components.esp32 import (
from esphome.components.network import (
add_use_address,
get_network_priority,
has_high_performance_networking,
has_high_performance_wifi,
ip_address_literal,
require_high_performance_wifi,
)
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
from esphome.config_helpers import filter_source_files_from_platform
@@ -30,6 +31,7 @@ from esphome.const import (
CONF_CERTIFICATE,
CONF_CERTIFICATE_AUTHORITY,
CONF_CHANNEL,
CONF_CHANNELS,
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
@@ -157,9 +159,15 @@ def has_native_wifi(
CONF_SAVE = "save"
CONF_BAND_MODE = "band_mode"
CONF_DEVICE_INFO = "device_info"
CONF_DPP = "dpp"
CONF_MIN_AUTH_MODE = "min_auth_mode"
CONF_ON_DPP_URI = "on_dpp_uri"
CONF_PHY_MODE = "phy_mode"
CONF_POST_CONNECT_ROAMING = "post_connect_roaming"
CONF_PRIVATE_KEY = "private_key"
CONF_AP_MODE = "ap_mode"
CONF_DNS_SERVERS = "dns_servers"
# Maximum number of WiFi networks that can be configured
# Limited to 127 because selected_sta_index_ is int8_t in C++
@@ -170,10 +178,15 @@ MAX_WIFI_NETWORKS = 127
# get best-effort connection attempts. Longer timeout ensures we exhaust all options
# before falling back to AP mode. Aligned with improv wifi_timeout default.
DEFAULT_AP_TIMEOUT = "90s"
DEFAULT_AP_TIMEOUT_PERIOD = cv.time_period(DEFAULT_AP_TIMEOUT)
DEFAULT_REBOOT_TIMEOUT = "15min"
# Both defaults also match the C++ initializers in wifi_component.h; codegen skips
# the setter when the config equals them.
# Similar to AP timeout, but reduced to allow repetitive enrollment attempts
# in the case of failure. Does not affect AP or scanning.
DEFAULT_DPP_TIMEOUT = "1min"
wifi_ns = cg.esphome_ns.namespace("wifi")
EAPAuth = wifi_ns.struct("EAPAuth")
ManualIP = wifi_ns.struct("ManualIP")
@@ -209,6 +222,32 @@ WIFI_8266_PHY_MODES = {
"11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G,
"11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N,
}
WIFI_AP_MODE_FALLBACK = "FALLBACK"
WIFI_AP_MODE_WISP = "WISP"
def _validate_ap_mode(value: str) -> str:
value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value)
if value == WIFI_AP_MODE_WISP:
# esp_netif_napt_enable() exists from ESP-IDF 5.1
cv.require_framework_version(
esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT"
)(value)
return value
def _validate_dns_servers(value: list) -> list:
if any(ip.is_unspecified for ip in value):
raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer")
if len(value) > 1:
cv.require_framework_version(
esp_idf=cv.Version(6, 0, 0),
extra_message="The access point offers a second DNS server from ESP-IDF 6.0",
)(value)
return value
WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition)
WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition)
WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition)
@@ -220,6 +259,14 @@ WiFiConfigureAction = wifi_ns.class_(
)
def _validate_dpp_device_info(value: object) -> str:
"""The supplicant puts the text on a space separated command line and reads it up to the next space."""
value = cv.string_strict(value)
if any(c.isspace() for c in value):
raise cv.Invalid("device_info cannot contain whitespace")
return value
def validate_password(value):
value = cv.string_strict(value)
if not value:
@@ -286,6 +333,26 @@ EAP_AUTH_SCHEMA = cv.All(
cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE),
)
# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method
DPP_SCHEMA = cv.Schema(
{
cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict),
cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info,
cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list(
cv.int_range(min=1, max=14)
),
cv.Optional(
CONF_TIMEOUT, default=DEFAULT_DPP_TIMEOUT
): cv.positive_time_period_milliseconds,
}
)
def _dpp_schema(value: ConfigType | None) -> ConfigType:
# A bare `dpp:` takes every default
return DPP_SCHEMA({} if value is None else value)
WIFI_NETWORK_BASE = cv.Schema(
{
cv.GenerateID(): cv.declare_id(WiFiAP),
@@ -304,6 +371,12 @@ WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend(
cv.Optional(
CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_AP_MODE, default=WIFI_AP_MODE_FALLBACK): _validate_ap_mode,
cv.Optional(CONF_DNS_SERVERS): cv.All(
cv.ensure_list(cv.ipv4address),
cv.Length(min=1, max=2),
_validate_dns_servers,
),
}
)
@@ -312,6 +385,19 @@ def wifi_network_ap(value):
if value is None:
value = {}
config = WIFI_NETWORK_AP(value)
if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
if not config.get(CONF_PASSWORD):
raise cv.Invalid(
"WISP mode requires a password for the access point",
path=[CONF_PASSWORD],
)
if config[CONF_AP_TIMEOUT].total_milliseconds == 0:
raise cv.Invalid(
"ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0",
path=[CONF_AP_TIMEOUT],
)
elif CONF_DNS_SERVERS in config:
raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS])
if CONF_MANUAL_IP in config and CORE.is_rp2:
raise cv.Invalid(
"Manual AP IP configuration is not supported on RP2040. "
@@ -361,16 +447,42 @@ def _apply_min_auth_mode_default(config):
def final_validate(config):
has_sta = bool(config.get(CONF_NETWORKS, True))
has_ap = CONF_AP in config
has_dpp = CONF_DPP in config
full_config = fv.full_config.get()
has_improv = "improv_ble" in full_config
has_improv_serial = "improv_serial" in full_config
has_captive_portal = "captive_portal" in full_config
has_web_server = "web_server" in full_config
if not (has_sta or has_ap or has_improv or has_improv_serial):
if not (has_sta or has_ap or has_dpp or has_improv or has_improv_serial):
raise cv.Invalid(
"Please specify at least an SSID or an Access Point to create."
"Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)."
)
if has_ap and not has_captive_portal and not has_web_server:
if CONF_ON_DPP_URI in config and not has_dpp:
raise cv.Invalid(f"'{CONF_ON_DPP_URI}' requires '{CONF_DPP}'")
ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP
if ap_is_wisp:
if not has_sta:
raise cv.Invalid(
"WISP mode requires at least one STA network (wifi.ssid or wifi.networks)"
)
if has_captive_portal:
raise cv.Invalid(
"captive_portal is not supported together with ap_mode: WISP"
)
# Every client packet passes through the WiFi driver, none through the device's own sockets
require_high_performance_wifi()
if CONF_DNS_SERVERS not in config[CONF_AP]:
# The access point passes the station's resolvers on, which a static address has to name
for network in [config, *config.get(CONF_NETWORKS, [])]:
static = network.get(CONF_MANUAL_IP)
if static and static[CONF_DNS1].is_unspecified:
raise cv.Invalid(
"manual_ip needs dns1 in WISP mode unless ap.dns_servers is set",
path=[CONF_MANUAL_IP, CONF_DNS1],
)
if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp:
_LOGGER.warning(
"WiFi AP is configured but neither captive_portal nor web_server is enabled. "
"The AP will not be usable for configuration or monitoring. "
@@ -543,6 +655,7 @@ CONFIG_SCHEMA = cv.All(
): STA_MANUAL_IP_SCHEMA,
cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA,
cv.Optional(CONF_AP): wifi_network_ap,
cv.Optional(CONF_DPP): cv.All(cv.only_on_esp32, _dpp_schema),
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(
CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT
@@ -589,6 +702,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
single=True
),
cv.Optional(CONF_ON_DPP_URI): automation.validate_automation(single=True),
cv.Optional(CONF_USE_PSRAM): cv.All(
cv.only_on_esp32, cv.requires_component("psram"), cv.boolean
),
@@ -708,19 +822,45 @@ async def to_code(config):
lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))),
)
# Skip the setter when the config matches the C++ initializer.
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT):
if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD:
cg.add(var.set_ap_timeout(ap_timeout))
if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP:
cg.add_define("USE_WIFI_AP_WISP")
add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True)
add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True)
if dns_servers := conf.get(CONF_DNS_SERVERS):
cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS")
# As lwIP stores an IPv4 address: the four bytes read as one little-endian word
for i, ip in enumerate(dns_servers):
cg.add_define(
f"WIFI_AP_WISP_DNS_{i}", int.from_bytes(ip.packed, "little")
)
cg.add_define("USE_WIFI_AP")
# The LN882H radio cannot run the AP and STA together; the fallback AP
# takes turns with the networks instead.
if CORE.is_ln882x:
cg.add_define("USE_WIFI_AP_EXCLUSIVE")
# The Pico W driver drops the AP station events; wifi_component_pico_w.cpp counts them
if CORE.is_rp2:
cg.add_build_flag("-Wl,--wrap=cyw43_cb_process_async_event")
# ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which
# drops SoftAP support / the LWIP DHCP server when AP mode is unused.
if CORE.is_esp32:
request_wifi(ap=CONF_AP in config)
if (dpp := config.get(CONF_DPP)) is not None:
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True)
if (key := dpp.get(CONF_PRIVATE_KEY)) is not None:
cg.add(var.set_dpp_key(key))
if (info := dpp.get(CONF_DEVICE_INFO)) is not None:
cg.add(var.set_dpp_device_info(info))
cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS])))
if (timeout := dpp[CONF_TIMEOUT]) != cv.time_period(DEFAULT_DPP_TIMEOUT):
cg.add(var.set_dpp_timeout(timeout))
cg.add_define("USE_WIFI_DPP")
# Disable Enterprise WiFi support if no EAP is configured
if CORE.is_esp32:
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap)
@@ -823,8 +963,8 @@ async def to_code(config):
if config.get(CONF_USE_PSRAM):
add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True)
# Apply high performance WiFi settings if high performance networking is enabled
if CORE.is_esp32 and has_high_performance_networking():
# Apply high performance WiFi settings if a component asked for them
if CORE.is_esp32 and has_high_performance_wifi():
# Check if PSRAM is guaranteed (set by psram component during final validation)
psram_guaranteed = psram_is_guaranteed()
@@ -891,6 +1031,12 @@ async def to_code(config):
var.get_disconnect_trigger(), [], on_disconnect_config
)
if (on_dpp_uri := config.get(CONF_ON_DPP_URI)) is not None:
cg.add_define("USE_WIFI_DPP_URI_TRIGGER")
await automation.build_callback_automation(
var, "add_on_dpp_uri_callback", [(cg.const_char_ptr, "uri")], on_dpp_uri
)
CORE.add_job(final_step)
+167 -56
View File
@@ -187,11 +187,11 @@ bool CompactString::operator==(const StringRef &other) const {
/// │ ↓ │
/// │ 5. FAILED → RESTARTING_ADAPTER │
/// │ - Normal: restart adapter, clear state │
/// │ - AP/improv active: skip restart, just disconnect │
/// │ - AP/DPP/improv active: skip restart, just disconnect │
/// │ ↓ │
/// │ 6. Loop back to start: │
/// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │
/// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │
/// │ - If AP/DPP/improv active → RETRY_HIDDEN (blind retry, see below)│
/// │ - Otherwise → SCAN_CONNECTING (rescan) │
/// │ ↓ │
/// │ 7. RESCAN → Apply stored priorities, sort again │
@@ -655,12 +655,17 @@ void WiFiComponent::setup() {
this->configured_power_save_ = this->power_save_;
#endif
#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP)
// The access point and the portals on it are provisioning surfaces: once the window has
// closed, end them (mirrors the teardown on a successful connection). The fallback block in
// loop() is gated so none of them is started again afterwards.
#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
// The access point, the portals on it and the DPP listen are provisioning surfaces: once the
// window has closed, end them (mirrors the teardown on a successful connection). The blocks in
// loop() that start them are gated so none of them is started again afterwards. A bridge AP
// carries no portal and stays up.
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
#ifdef USE_WIFI_DPP
this->stop_dpp_listen_();
#endif
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
if (this->ap_setup_) {
ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
@@ -672,8 +677,9 @@ void WiFiComponent::setup() {
return;
}
#endif
this->wifi_mode_({}, false);
this->disable_ap_();
}
#endif // USE_WIFI_AP
});
}
#endif
@@ -790,6 +796,11 @@ void WiFiComponent::start() {
this->start_ap_portal_();
#endif // USE_WIFI_AP
}
#ifdef USE_WIFI_DPP
// Without networks there is nothing to try first, so listen for a configurator right away
if (!this->has_sta() && this->is_dpp_active_() && this->wifi_mode_(true, {}))
this->start_dpp_listen_(millis());
#endif
#ifdef USE_IMPROV_BLE
if (!this->has_sta() && improv_ble::global_improv_component != nullptr) {
if (this->wifi_mode_(true, {}))
@@ -849,7 +860,7 @@ void WiFiComponent::loop() {
break;
}
// Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP
bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_();
bool portal_active = this->is_provisioning_active_();
uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
if (now - this->action_started_ > cooldown_duration) {
// After cooldown we either restarted the adapter because of
@@ -911,8 +922,8 @@ void WiFiComponent::loop() {
// The networks are not tried while the AP is up, so it pauses for
// them once nobody has used it for a while. Without networks the AP
// is all there is and stays.
if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS &&
(this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS))
if (this->has_sta() && now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS &&
(this->ap_clients_ == 0 || now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS))
this->pause_exclusive_ap_();
break;
#endif
@@ -922,31 +933,35 @@ void WiFiComponent::loop() {
return;
}
#ifdef USE_WIFI_AP
bool provisioning_closed = false;
#ifdef USE_PROVISIONING
#ifdef USE_WIFI_AP_WISP
// The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
if (!this->ap_setup_ && this->ap_dns_known_() &&
(this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
ESP_LOGI(TAG, "Starting AP");
this->ap_changed_ = now;
this->setup_ap_config_();
}
#elif defined(USE_WIFI_AP)
// Once the provisioning window has closed, don't bring up the fallback AP (or
// the captive portal on it) - the device must stay unprovisionable until it is
// power-cycled.
provisioning_closed =
provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed();
#endif
if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) {
if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) {
if (this->ap_timeout_ != 0 &&
(now - this->last_connected_ > this->ap_timeout_)
#ifdef USE_WIFI_AP_EXCLUSIVE
// After a pause, or a start that failed, the networks get a full
// ap_timeout before the AP is tried again.
&& now - this->ap_exclusive_changed_ > this->ap_timeout_
&& now - this->ap_changed_ > this->ap_timeout_
#endif
) {
ESP_LOGI(TAG, "Starting fallback AP");
#ifdef USE_WIFI_AP_EXCLUSIVE
this->ap_exclusive_changed_ = now;
this->ap_changed_ = now;
#endif
this->setup_ap_config_();
// Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns.
if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) {
// A portal is only started behind an AP that came up: the portal keeps a reference to the web server
// on every start, and a failed start is tried again later
if (this->ap_setup_) {
#ifdef USE_CAPTIVE_PORTAL
// Reset so we force one full scan after captive portal starts
// (previous scans were filtered because captive portal wasn't active yet)
@@ -966,29 +981,43 @@ void WiFiComponent::loop() {
improv_ble::global_improv_component->start();
}
}
#endif
if (!this->has_ap() && this->reboot_timeout_ != 0) {
if (now - this->last_connected_ > this->reboot_timeout_) {
bool suppress = false;
if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) {
bool suppress = false;
#if defined(USE_WIFI_AP) && (defined(USE_RTL87XX) || defined(USE_LN882X))
// The Realtek driver reports no stations and the Lightning one is unverified, so a running fallback AP
// holds the reboot there as it always did
suppress = this->ap_setup_;
#elif defined(USE_WIFI_AP)
// A station on the fallback AP is using the device
suppress = this->ap_setup_ && this->ap_clients_ != 0;
#endif
#ifdef USE_PROVISIONING
// Don't reboot while a provisioning window is pending (device unprovisioned).
// The device is legitimately waiting to be onboarded (Wi-Fi must come up
// before the controller can set credentials), and an auto-reboot would reopen
// the window without the deliberate power cycle / reset that is meant to be
// required. Resumes normal reboot behavior once provisioned.
suppress = provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
// Don't reboot while a provisioning window is pending (device unprovisioned).
// The device is legitimately waiting to be onboarded (Wi-Fi must come up
// before the controller can set credentials), and an auto-reboot would reopen
// the window without the deliberate power cycle / reset that is meant to be
// required. Resumes normal reboot behavior once provisioned.
suppress |= provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
#endif
if (!suppress) {
ESP_LOGE(TAG, "Can't connect; rebooting");
App.reboot();
}
if (!suppress) {
ESP_LOGE(TAG, "Can't connect; rebooting");
App.reboot();
}
}
}
#ifdef USE_WIFI_DPP
// Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again
// here, at most once per timeout
if (!this->has_sta() && !this->dpp_listening_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED &&
now - this->dpp_listen_started_ > this->dpp_timeout_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) {
this->start_dpp_listen_(now);
}
#endif
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
// Check if power save mode needs to be updated based on high-performance requests
if (this->high_performance_semaphore_ != nullptr) {
@@ -1062,10 +1091,12 @@ void WiFiComponent::setup_ap_config_() {
this->ap_.set_ssid(ssid_buf);
}
}
// Reset before the AP exists: afterwards only the platform's event path writes the counter
this->ap_clients_ = 0;
this->ap_setup_ = this->wifi_start_ap_(this->ap_);
#ifdef USE_WIFI_AP_EXCLUSIVE
// A failed start must not leave the radio in AP mode, which would refuse
// the STA attempts until the next try.
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
// A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
// the STA attempts until the next try, and a bridge would take clients it cannot serve.
if (!this->ap_setup_)
this->wifi_mode_({}, false);
#endif
@@ -1105,10 +1136,7 @@ void WiFiComponent::pause_exclusive_ap_() {
ESP_LOGI(TAG, "Pausing AP to try the networks");
// Scanning and connecting turn STA back on.
this->disable_ap_();
this->ap_setup_ = false;
// Clients drop with the AP without a disconnect event for each.
this->ap_clients_ = 0;
this->ap_exclusive_changed_ = App.get_loop_component_start_time();
this->ap_changed_ = App.get_loop_component_start_time();
// A fresh connection cycle, as at boot, rather than resuming the one the
// AP cut short.
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
@@ -1117,10 +1145,22 @@ void WiFiComponent::pause_exclusive_ap_() {
}
#endif
#ifdef USE_WIFI_AP_WISP
void WiFiComponent::fail_bridge_() {
this->ap_setup_ = false;
this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
this->wifi_mode_({}, false);
}
#endif
void WiFiComponent::disable_ap_() {
this->end_ap_portal_();
ESP_LOGD(TAG, "Disabling AP");
this->wifi_mode_({}, false);
// The fallback block may bring the AP back after a later disconnect; its clients dropped with it without
// a disconnect event for each
this->ap_setup_ = false;
this->ap_clients_ = 0;
}
void WiFiComponent::set_ap(const WiFiAP &ap) {
@@ -1330,13 +1370,23 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) {
ESP_LOGE(TAG, "wifi_sta_connect_ failed");
// Enter cooldown to allow WiFi hardware to stabilize
// (immediate failure suggests hardware not ready, different from connection timeout)
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
} else {
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING;
this->start_cooldown_();
return;
}
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING;
this->action_started_ = millis();
}
void WiFiComponent::start_cooldown_() {
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
this->action_started_ = millis();
#ifdef USE_WIFI_DPP
// The cooldown is the configurator's window; the attempt that ends it stops the listen again
if (!this->dpp_listening_ && this->is_dpp_active_() && this->wifi_mode_(true, {}))
this->start_dpp_listen_(this->action_started_);
#endif
}
const LogString *get_signal_bars(int8_t rssi) {
// LOWER ONE QUARTER BLOCK
// Unicode: U+2582, UTF-8: E2 96 82
@@ -1449,9 +1499,21 @@ void WiFiComponent::disable() {
this->state_ = WIFI_COMPONENT_STATE_DISABLED;
#ifdef USE_WIFI_LEASE_CACHE
this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID);
#endif
#ifdef USE_WIFI_DPP
this->stop_dpp_listen_();
#endif
this->wifi_disconnect_();
this->wifi_mode_(false, false);
#ifdef USE_WIFI_AP
// The AP went down with the radio; enable() has to configure it, and its portal, again
this->end_ap_portal_();
this->ap_setup_ = false;
this->ap_clients_ = 0;
#endif
#ifdef USE_WIFI_AP_WISP
this->ap_changed_ = 0; // at once, not after ap_timeout
#endif
}
void WiFiComponent::start_scanning() {
@@ -1696,6 +1758,12 @@ void WiFiComponent::dump_config() {
ESP_LOGCONFIG(TAG, " Disabled");
return;
}
#ifdef USE_WIFI_DPP
ESP_LOGCONFIG(TAG,
" DPP channels: %s\n"
" DPP timeout: %" PRIu32 " ms",
this->dpp_channels_, this->dpp_timeout_);
#endif
#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
const char *band_mode_s;
switch (this->band_mode_) {
@@ -1742,17 +1810,22 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
// Reset to initial phase on successful connection (don't log transition, just reset state)
this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
this->num_retried_ = 0;
#ifdef USE_WIFI_AP
#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
if (this->has_ap()) {
this->disable_ap_();
}
#endif
#ifdef USE_WIFI_DPP
this->stop_dpp_listen_();
#endif
#ifdef USE_IMPROV_BLE
if (this->is_improv_ble_active_()) {
improv_ble::global_improv_component->stop();
}
#endif
// The fallback and reboot checks later in this pass must see the connection, not the old timestamp
this->last_connected_ = now;
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED;
// Refresh is_connected() cache; loop()'s refresh ran before this transition.
this->update_connected_state_();
@@ -1922,7 +1995,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
}
// No hidden networks - always go through RESTARTING_ADAPTER phase
// This ensures num_retried_ gets reset and a fresh scan is triggered
// The actual adapter restart will be skipped if captive portal/improv is active
// The actual adapter restart will be skipped if DPP/captive portal/improv is active
return WiFiRetryPhase::RESTARTING_ADAPTER;
case WiFiRetryPhase::RETRY_HIDDEN:
@@ -1945,7 +2018,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
}
// Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER
// This ensures num_retried_ gets reset and a fresh scan is triggered
// The actual adapter restart will be skipped if captive portal/improv is active
// The actual adapter restart will be skipped if DPP/captive portal/improv is active
return WiFiRetryPhase::RESTARTING_ADAPTER;
case WiFiRetryPhase::RESTARTING_ADAPTER:
@@ -1975,14 +2048,14 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
// attempting to connect to all configured networks in sequence.
// Captive portal needs scan results to show available networks.
// If captive portal is active, only skip scanning if we've done a scan after it started.
// If only improv is active (no captive portal), skip scanning since improv doesn't need results.
// If only DPP or improv is active (no captive portal), skip scanning since neither needs results.
if (this->is_captive_portal_active_()) {
if (this->has_completed_scan_after_captive_portal_start_) {
return WiFiRetryPhase::RETRY_HIDDEN;
}
// Need to scan for captive portal
} else if (this->is_improv_ble_active_()) {
// Improv doesn't need scan results
} else if (this->is_improv_ble_active_() || this->is_dpp_active_()) {
// Improv and DPP don't need scan results
return WiFiRetryPhase::RETRY_HIDDEN;
}
return WiFiRetryPhase::SCAN_CONNECTING;
@@ -2072,7 +2145,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
// Skip actual adapter restart if a portal/improv is active
// This allows state machine to reset num_retried_ and trigger fresh scan
// without disrupting the portal/improv connection
if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) {
if (!this->is_provisioning_active_()) {
this->restart_adapter();
} else {
// Even when skipping full restart, disconnect to clear driver state
@@ -2080,15 +2153,14 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
this->wifi_disconnect_();
}
// Clear scan flag - we're starting a new retry cycle
// This is critical for captive portal/improv flow: when determine_next_phase_()
// This is critical for DPP/captive portal/improv flow: when determine_next_phase_()
// returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_()
// will see BLIND_RETRY mode and treat ALL networks as candidates,
// effectively cycling through all configured networks without scan results.
this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY;
// Always enter cooldown after restart (or skip-restart) to allow stabilization
// Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
this->action_started_ = millis();
this->start_cooldown_();
// Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
return true;
@@ -2324,6 +2396,32 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
#endif
}
#ifdef USE_WIFI_DPP
void WiFiComponent::start_dpp_listen_(uint32_t now) {
this->dpp_listen_started_ = now;
this->dpp_listening_ = this->wifi_dpp_start_listen_();
}
void WiFiComponent::stop_dpp_listen_() {
if (!this->dpp_listening_)
return;
this->wifi_dpp_stop_listen_();
this->dpp_listening_ = false;
}
#endif
bool WiFiComponent::is_dpp_active_() {
#ifdef USE_WIFI_DPP
// A configurator is waited for once the networks have had their timeout, or from boot without any; the retry
// machine then treats DPP like a portal (blind retries, a long cooldown) whether the supplicant is listening at
// this moment or not
return !this->is_provisioning_closed_() &&
(!this->has_sta() || App.get_loop_component_start_time() - this->last_connected_ > this->dpp_timeout_);
#else
return false;
#endif
}
bool WiFiComponent::is_captive_portal_active_() {
#ifdef USE_CAPTIVE_PORTAL
return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active();
@@ -2369,6 +2467,19 @@ bool WiFiComponent::is_improv_ble_active_() {
#endif
}
bool WiFiComponent::is_provisioning_active_() {
return this->is_ap_portal_active_() || this->is_improv_ble_active_() || this->is_dpp_active_();
}
bool WiFiComponent::is_provisioning_closed_() {
#ifdef USE_PROVISIONING
// The device stays unprovisionable until it is power-cycled
return provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed();
#else
return false;
#endif
}
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
bool WiFiComponent::request_high_performance() {
// Already configured for high performance - request satisfied
+80 -4
View File
@@ -19,6 +19,7 @@
#include <atomic>
#include <limits>
#include <span>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
@@ -50,6 +51,13 @@ extern "C" {
}
#include <WiFi.h>
#ifdef USE_WIFI_AP
// The linker routes the driver's firmware events here; wifi/__init__.py adds the wrap flag
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev);
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif
#endif
#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
@@ -479,6 +487,21 @@ class WiFiComponent final : public Component {
void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; }
#endif // USE_WIFI_AP
#ifdef USE_WIFI_DPP
// Code generation passes string literals that live in flash
void set_dpp_key(const char *key) { this->dpp_key_ = key; }
void set_dpp_device_info(const char *info) { this->dpp_device_info_ = info; }
void set_dpp_channels(const char *channels) { this->dpp_channels_ = channels; }
void set_dpp_timeout(uint32_t timeout) { this->dpp_timeout_ = timeout; }
#ifdef USE_WIFI_DPP_URI_TRIGGER
template<typename F> void add_on_dpp_uri_callback(F &&callback) {
this->dpp_uri_callback_.add(std::forward<F>(callback));
}
#endif
/// The Easy Connect URI to show as a QR code, nullptr until the supplicant has generated it
const char *get_dpp_uri() const { return this->dpp_uri_.get(); }
#endif
void enable();
void disable();
bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
@@ -721,6 +744,19 @@ class WiFiComponent final : public Component {
#endif
#endif // USE_WIFI_AP
#ifdef USE_WIFI_AP_WISP
/// Whether there is an IPv4 resolver to offer AP clients: a configured one, or the station's
bool ap_dns_known_();
/// Hand the AP DHCP server its resolvers; false when one could not be set
bool write_ap_dns_();
/// Take the bridge down so loop() runs its setup again after ap_timeout
void fail_bridge_();
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
/// Pass the station's resolvers on to AP clients when they changed
void apply_ap_dns_();
#endif
#endif
void print_connect_params_();
WiFiAP build_params_for_current_phase_();
@@ -831,6 +867,23 @@ class WiFiComponent final : public Component {
network::IPAddress wifi_gateway_ip_();
network::IPAddress wifi_dns_ip_(int num);
#ifdef USE_WIFI_DPP
// Starts listening for a DPP configurator; the platform half reports whether the driver accepted
void start_dpp_listen_(uint32_t now);
void stop_dpp_listen_();
#endif
/// Wait before the next attempt; while a configurator is waited for, the wait is its listen window
void start_cooldown_();
#ifdef USE_WIFI_DPP
void wifi_dpp_init_();
bool wifi_dpp_start_listen_();
void wifi_dpp_stop_listen_();
#endif
bool is_dpp_active_();
/// A portal, improv or DPP is waiting for credentials, so the retry machine must not disturb the radio
bool is_provisioning_active_();
/// The provisioning window has closed, so no new provisioning surface may start
bool is_provisioning_closed_();
bool is_captive_portal_active_();
/// captive_portal or the web_server AP mode is serving a user on the access point
bool is_ap_portal_active_();
@@ -906,6 +959,11 @@ class WiFiComponent final : public Component {
#ifdef USE_RP2
static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result);
void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result);
#ifdef USE_WIFI_AP
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
friend void ::__wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev);
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif
#endif
#ifdef USE_LIBRETINY
@@ -971,8 +1029,8 @@ class WiFiComponent final : public Component {
float output_power_{NAN};
uint32_t action_started_;
uint32_t last_connected_{0};
#ifdef USE_WIFI_AP_EXCLUSIVE
uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused
#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
uint32_t ap_changed_{0}; // When the AP was last started or paused
#endif
uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py
uint32_t roaming_last_check_{0};
@@ -982,6 +1040,17 @@ class WiFiComponent final : public Component {
// WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the
// 1-byte members so they pack into its trailing bytes instead of padding after it
WiFiAP ap_;
#endif // USE_WIFI_AP
#ifdef USE_WIFI_DPP
const char *dpp_key_{nullptr};
const char *dpp_device_info_{nullptr};
const char *dpp_channels_{nullptr};
std::unique_ptr<char[]> dpp_uri_; // written by the event task before the URI event is queued
#ifdef USE_WIFI_DPP_URI_TRIGGER
CallbackManager<void(const char *)> dpp_uri_callback_;
#endif
uint32_t dpp_timeout_{60000}; // Keep in sync with DEFAULT_DPP_TIMEOUT in __init__.py
uint32_t dpp_listen_started_{0};
#endif
// 1-byte enums and integers
@@ -1066,8 +1135,15 @@ class WiFiComponent final : public Component {
bool scan_done_{false};
bool ap_setup_{false};
bool ap_started_{false};
#ifdef USE_WIFI_AP_EXCLUSIVE
uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing
#ifdef USE_WIFI_DPP
bool dpp_listening_{false};
#endif
#ifdef USE_WIFI_AP
// Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing.
// Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and
// in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up
// and only writes it (a reset) while the AP is down, so no lock is needed.
uint8_t ap_clients_{0};
#endif
bool passive_scan_{false};
bool has_saved_wifi_settings_{false};
@@ -572,6 +572,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
break;
}
case EVENT_SOFTAPMODE_STACONNECTED: {
#ifdef USE_WIFI_AP
global_wifi_component->ap_clients_++;
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
auto it = event->event_info.sta_connected;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -581,6 +584,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
break;
}
case EVENT_SOFTAPMODE_STADISCONNECTED: {
#ifdef USE_WIFI_AP
if (global_wifi_component->ap_clients_ > 0)
global_wifi_component->ap_clients_--;
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
auto it = event->event_info.sta_disconnected;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -24,6 +24,10 @@
#endif
#endif
#ifdef USE_WIFI_DPP
#include <esp_dpp.h>
#endif
#ifdef USE_WIFI_AP
#include "dhcpserver/dhcpserver.h"
#endif // USE_WIFI_AP
@@ -64,6 +68,15 @@ static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-
static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#ifdef USE_WIFI_DPP
/// Terminated copies of the received station credentials; the supplicant's wifi_config_t would grow every event
static void copy_terminated(char *dst, const uint8_t *src, size_t len) {
memcpy(dst, src, len);
dst[len] = '\0';
}
#endif
struct IDFWiFiEvent {
esp_event_base_t event_base;
int32_t event_id;
@@ -76,6 +89,10 @@ struct IDFWiFiEvent {
wifi_event_ap_stadisconnected_t ap_stadisconnected;
wifi_event_ap_probe_req_rx_t ap_probe_req_rx;
wifi_event_bss_rssi_low_t bss_rssi_low;
#ifdef USE_WIFI_DPP
SavedWifiSettings *dpp_credentials; // owned by the event; the rare 98 byte record does not widen the union
wifi_event_dpp_failed_t dpp_failed;
#endif
ip_event_got_ip_t ip_got_ip;
#if USE_NETWORK_IPV6
ip_event_got_ip6_t ip_got_ip6;
@@ -125,6 +142,23 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t));
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) {
memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t));
#ifdef USE_WIFI_DPP
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_URI_READY) {
// Kept on the component for the whole boot; the queued event only announces it
const auto *uri = static_cast<const wifi_event_dpp_uri_ready_t *>(event_data);
const size_t len = strnlen(uri->uri, uri->uri_data_len);
auto copy = std::make_unique<char[]>(len + 1);
copy_terminated(copy.get(), reinterpret_cast<const uint8_t *>(uri->uri), len);
global_wifi_component->dpp_uri_ = std::move(copy);
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) {
const auto &sta = static_cast<const wifi_event_dpp_config_received_t *>(event_data)->wifi_cfg.sta;
auto *credentials = new SavedWifiSettings; // NOLINT(cppcoreguidelines-owning-memory)
copy_terminated(credentials->ssid, sta.ssid, sizeof(sta.ssid));
copy_terminated(credentials->password, sta.password, sizeof(sta.password));
event.data.dpp_credentials = credentials;
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_FAILED) {
memcpy(&event.data.dpp_failed, event_data, sizeof(wifi_event_dpp_failed_t));
#endif
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
} else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) {
memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t));
@@ -141,6 +175,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory)
memcpy(to_send, &event, sizeof(IDFWiFiEvent));
if (!global_wifi_component->event_queue_.push(to_send)) {
#ifdef USE_WIFI_DPP
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD)
delete to_send->data.dpp_credentials; // NOLINT(cppcoreguidelines-owning-memory)
#endif
delete to_send; // NOLINT(cppcoreguidelines-owning-memory)
}
}
@@ -224,6 +262,9 @@ void WiFiComponent::wifi_lazy_init_() {
}
s_wifi_started = true;
this->wifi_initialized_ = true;
#ifdef USE_WIFI_DPP
this->wifi_dpp_init_();
#endif
return;
}
@@ -242,9 +283,32 @@ void WiFiComponent::wifi_lazy_init_() {
ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
return;
}
this->wifi_initialized_ = true;
#ifdef USE_WIFI_DPP
this->wifi_dpp_init_();
#endif
}
#ifdef USE_WIFI_DPP
void WiFiComponent::wifi_dpp_init_() {
// With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
esp_err_t err = esp_supp_dpp_init();
#else
esp_err_t err = esp_supp_dpp_init(nullptr);
#endif
if (err != ESP_OK) {
ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err));
return;
}
err = esp_supp_dpp_bootstrap_gen(this->dpp_channels_, DPP_BOOTSTRAP_QR_CODE, this->dpp_key_, this->dpp_device_info_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_bootstrap_gen"), esp_err_to_name(err));
}
}
#endif
bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
esp_err_t err;
wifi_mode_t current_mode = WIFI_MODE_NULL;
@@ -354,6 +418,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
#ifdef USE_WIFI_DPP
// A connect attempt ends the supplicant's listen; the next cooldown starts it again
this->stop_dpp_listen_();
#endif
// https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t
wifi_config_t conf;
@@ -928,6 +996,15 @@ const char *get_disconnect_reason_str(uint8_t reason) {
}
}
#ifdef USE_WIFI_AP
// The driver's own station list cannot drift, so the count is read from it rather than kept by arithmetic
static void read_ap_clients(uint8_t &count) {
wifi_sta_list_t sta_list;
if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK)
count = sta_list.num;
}
#endif
bool WiFiComponent::wifi_loop_() {
// Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_),
// while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa.
@@ -945,6 +1022,11 @@ bool WiFiComponent::wifi_loop_() {
uint16_t dropped = this->event_queue_.get_and_reset_dropped_count();
if (dropped > 0) {
ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped);
#ifdef USE_WIFI_AP
// A dropped join or leave is made up for by reading the list again
if (this->ap_setup_)
read_ap_clients(this->ap_clients_);
#endif
}
return true;
}
@@ -1054,6 +1136,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
const auto &it = data->data.ip_got_ip;
ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw));
this->got_ipv4_address_ = true;
#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS)
this->apply_ap_dns_();
#endif
#ifdef USE_WIFI_LEASE_CACHE
this->save_lease_settings_(); // events are drained on the main loop
#endif
@@ -1172,6 +1257,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {
ESP_LOGV(TAG, "AP start");
this->ap_started_ = true;
#ifdef USE_WIFI_AP_WISP
// NAPT needs the interface up, so it cannot be part of the setup. Without it the clients have no way out, so
// the AP goes down and its setup runs again, which brings it back up through this event.
if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) {
ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err));
this->fail_bridge_();
}
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) {
ESP_LOGV(TAG, "AP stop");
@@ -1186,6 +1279,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STACONNECTED) {
#ifdef USE_WIFI_AP
read_ap_clients(this->ap_clients_);
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
const auto &it = data->data.ap_staconnected;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -1194,6 +1290,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STADISCONNECTED) {
#ifdef USE_WIFI_AP
read_ap_clients(this->ap_clients_);
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
const auto &it = data->data.ap_stadisconnected;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -1201,6 +1300,31 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf);
#endif
#ifdef USE_WIFI_DPP
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_URI_READY) {
ESP_LOGD(TAG, "DPP URI: %s", this->dpp_uri_.get());
#ifdef USE_WIFI_DPP_URI_TRIGGER
this->dpp_uri_callback_.call(this->dpp_uri_.get());
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_CFG_RECVD) {
const std::unique_ptr<SavedWifiSettings> credentials(data->data.dpp_credentials);
this->dpp_listening_ = false;
ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid);
// The reboot timer starts over for the connect attempt, as after a boot with saved credentials
this->last_connected_ = App.get_loop_component_start_time();
// Saved like portal credentials; from a cooldown this already restarts the retries
this->save_wifi_sta(credentials->ssid, credentials->password);
if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) {
// Without networks the station machine was never started; an AP without a portal sits in AP
this->start_connecting(*this->get_selected_sta_());
}
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_FAILED) {
this->dpp_listening_ = false;
ESP_LOGW(TAG, "DPP failed: %s", esp_err_to_name(data->data.dpp_failed.failure_reason));
#endif // USE_WIFI_DPP
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) {
const auto &it = data->data.ip_assigned_ip_to_client;
@@ -1349,6 +1473,19 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
}
#endif
#ifdef USE_WIFI_AP_WISP
// Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would
// offer the AP's own address, so a failure fails the setup and it is tried again.
uint8_t dns_offer = 1;
err =
esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer));
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to enable DHCP DNS option: %s", esp_err_to_name(err));
}
if (err != ESP_OK || !this->write_ap_dns_())
return false;
#endif
err = esp_netif_dhcps_start(s_ap_netif);
if (err != ESP_OK) {
@@ -1419,6 +1556,20 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() {
}
#endif // USE_WIFI_AP
#ifdef USE_WIFI_DPP
bool WiFiComponent::wifi_dpp_start_listen_() {
esp_err_t err = esp_supp_dpp_start_listen();
if (err != ESP_OK) {
ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_start_listen"), esp_err_to_name(err));
return false;
}
ESP_LOGD(TAG, "DPP listening for a configurator");
return true;
}
void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); }
#endif
bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); }
bssid_t WiFiComponent::wifi_bssid() {
@@ -1489,6 +1640,81 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) {
return network::IPAddress(dns_ip);
}
#ifdef USE_WIFI_AP_WISP
// A DHCP server interface takes a backup resolver from ESP-IDF 6.0
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
static constexpr size_t AP_DNS_COUNT = 2;
#else
static constexpr size_t AP_DNS_COUNT = 1;
#endif
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
// The configured resolvers as lwIP stores them, from codegen
static constexpr uint32_t AP_DNS[] = {
WIFI_AP_WISP_DNS_0,
#ifdef WIFI_AP_WISP_DNS_1
WIFI_AP_WISP_DNS_1,
#endif
};
#endif
static uint32_t ap_dns_to_offer(size_t i) {
#ifdef USE_WIFI_AP_WISP_STATIC_DNS
return i < std::size(AP_DNS) ? AP_DNS[i] : 0;
#else
return ip_addr_get_ip4_u32(dns_getserver(i));
#endif
}
bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; }
bool WiFiComponent::write_ap_dns_() {
bool ok = true;
for (size_t i = 0; i < AP_DNS_COUNT; i++) {
const uint32_t addr = ap_dns_to_offer(i);
if (addr == 0)
continue;
esp_netif_dns_info_t info{};
info.ip.type = ESP_IPADDR_TYPE_V4;
info.ip.u_addr.ip4.addr = addr;
esp_err_t err = esp_netif_set_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), &info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to set DHCP DNS server %u: %s", (unsigned) i, esp_err_to_name(err));
ok = false;
} else {
ESP_LOGV(TAG, "DHCP DNS server: " IPSTR, IP2STR(&info.ip.u_addr.ip4));
}
}
return ok;
}
#ifndef USE_WIFI_AP_WISP_STATIC_DNS
void WiFiComponent::apply_ap_dns_() {
if (!this->ap_setup_)
return; // loop() brings the bridge up once the station has a resolver, and its setup writes them
// The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the
// next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address.
bool changed = false;
for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) {
const uint32_t addr = ap_dns_to_offer(i);
esp_netif_dns_info_t current{};
changed =
addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast<esp_netif_dns_type_t>(i), &current) != ESP_OK ||
current.ip.u_addr.ip4.addr != addr);
}
if (!changed)
return;
// The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined
#ifdef USE_WIFI_MANUAL_IP
const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip());
#else
const bool ok = this->wifi_ap_ip_config_({});
#endif
if (!ok)
this->fail_bridge_();
}
#endif // USE_WIFI_AP_WISP_STATIC_DNS
#endif // USE_WIFI_AP_WISP
} // namespace esphome::wifi
#endif // USE_ESP32
#endif
@@ -629,7 +629,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
break;
}
case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: {
#ifdef USE_WIFI_AP_EXCLUSIVE
#ifdef USE_WIFI_AP
this->ap_clients_++;
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
@@ -641,7 +641,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) {
break;
}
case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: {
#ifdef USE_WIFI_AP_EXCLUSIVE
#ifdef USE_WIFI_AP
if (this->ap_clients_ > 0)
this->ap_clients_--;
#endif
@@ -881,6 +881,7 @@ bool WiFiComponent::wifi_loop_() {
// so if pop() returned nullptr above we can skip this check.
uint16_t dropped = this->event_queue_.get_and_reset_dropped_count();
if (dropped > 0) {
// A dropped station event cannot be made up for: the BK72xx core has no station query
ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped);
}
return true;
@@ -59,6 +59,11 @@ bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
if (ap.value()) {
cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE);
} else {
#ifdef USE_WIFI_AP
// Takes down the Arduino AP interface and its DHCP server; a later beginAP() is refused
// while they are still up
WiFi.disconnectAP();
#endif
cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, false, CYW43_COUNTRY_WORLDWIDE);
}
this->ap_started_ = ap.value();
@@ -238,12 +243,16 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) {
// Pass nullptr for empty password — CYW43 uses the password pointer (not length)
// to choose between OPEN and WPA2 auth mode.
const char *ap_password = ap.password_.empty() ? nullptr : ap.password_.c_str();
WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1);
if (WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1) != WL_CONNECTED) {
ESP_LOGW(TAG, "beginAP failed");
return false;
}
return true;
}
network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.softAPIP()}; }
#endif // USE_WIFI_AP
bool WiFiComponent::wifi_disconnect_() {
@@ -372,5 +381,36 @@ bool WiFiComponent::wifi_loop_() {
void WiFiComponent::wifi_pre_setup_() {}
} // namespace esphome::wifi
#ifdef USE_WIFI_AP
// Firmware event ids the driver header leaves out
static constexpr uint32_t AP_STATION_ASSOC_IND = 8;
// The driver hands every firmware event to cyw43_cb_process_async_event and ignores the AP station
// ones, so they are counted here before it sees them. Runs in the driver's context. Verified with
// arduino-pico 6.1.1 on a Pico 2W: a join gives AUTH_IND then ASSOC_IND, a leave gives DISASSOC_IND.
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
// Names are mandated by the --wrap linker mechanism.
extern "C" void __real_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev);
extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev) {
if (ev->interface == CYW43_ITF_AP) {
switch (ev->event_type) {
case AP_STATION_ASSOC_IND:
// A refused association carries its status here and has no leave event to balance it. A
// reassociation comes from a station that is already counted, whether or not its leave was seen.
if (ev->status == CYW43_STATUS_SUCCESS)
esphome::wifi::global_wifi_component->ap_clients_++;
break;
case CYW43_EV_DISASSOC_IND:
case CYW43_EV_DEAUTH_IND:
if (esphome::wifi::global_wifi_component->ap_clients_ > 0)
esphome::wifi::global_wifi_component->ap_clients_--;
break;
}
}
__real_cyw43_cb_process_async_event(cb_data, ev);
}
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_WIFI_AP
#endif
#endif
+7
View File
@@ -497,10 +497,16 @@
#define USE_CAPTIVE_PORTAL_GZIP
#endif
#define USE_WIFI_11KV_SUPPORT
#define USE_WIFI_DPP
#define USE_WIFI_DPP_URI_TRIGGER
#define USE_WIFI_FAST_CONNECT
#define USE_WIFI_FAST_CONNECT_IN_FLASH
#define USE_WIFI_LEASE_CACHE
#define USE_WIFI_LEASE_CACHE_IN_FLASH
#define USE_WIFI_AP_WISP
#define USE_WIFI_AP_WISP_STATIC_DNS
#define WIFI_AP_WISP_DNS_0 0x08080808
#define WIFI_AP_WISP_DNS_1 0x04040808
#define USE_WIFI_PHY_MODE
#define USE_WIFI_IP_STATE_LISTENERS
#define USE_WIFI_SCAN_RESULTS_LISTENERS
@@ -563,6 +569,7 @@
#define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2
#ifdef USE_ESP32
#define MICRO_WAKE_WORD_TASK_CORE 0
#define USE_MICRO_WAKE_WORD
#define USE_MICRO_WAKE_WORD_INTERNAL_RAM
#define USE_MICRO_WAKE_WORD_VAD
+3 -3
View File
@@ -7,7 +7,7 @@
namespace esphome {
bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram) {
bool use_psram, BaseType_t core_id) {
if (this->handle_ != nullptr) {
// Task is already created; must call destroy() first
return false;
@@ -38,8 +38,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return false;
}
this->handle_ =
xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get());
this->handle_ = xTaskCreateStaticPinnedToCore(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(),
this->tcb_.get(), core_id);
if (this->handle_ == nullptr) {
this->deallocate();
return false;
+2 -1
View File
@@ -37,9 +37,10 @@ class StaticTask {
/// @param param Parameter passed to task function
/// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
/// @param core_id CPU core, or tskNO_AFFINITY to leave scheduling unrestricted
/// @return true on success; false if either allocation fails or the task cannot be created
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram);
bool use_psram, BaseType_t core_id = tskNO_AFFINITY);
/// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call.
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
+4
View File
@@ -85,6 +85,10 @@ dependencies:
version: "1.1.0"
rules:
- if: "idf_version >=6.0.0"
espressif/ksz8851snl:
version: "1.2.0"
rules:
- if: "idf_version >=6.0.0"
# Keep in sync with esphome/components/ethernet/__init__.py
espressif/ch390:
version: "0.3.0"
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.5.0
click==8.5.0
aioesphomeapi==46.11.0
aioesphomeapi==46.12.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -93,12 +93,12 @@ def test_transform_matches_model_support(
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays
# The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping).
model = MODELS["ESP32-8048S070"]
model = MODELS["ST7701S"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)...
@@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x(
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_pure_rgb_model_has_no_hardware_transform(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A model with no init sequence has no command interface, so no transform
can be applied in hardware and every rotation must be done in software.
Previously such models claimed mirror_x/mirror_y support, so a rotation of
180 was handed to the (nonexistent) hardware and the display was not rotated.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["ESP32-8048S070"]
assert model.initsequence == ()
assert model.transforms == set()
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
for rotation in (90, 180, 270):
config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation})
assert not model.rotation_as_transform(config)
metadata = get_display_metadata(config["id"])
assert metadata.rotation == rotation
assert not metadata.has_hardware_rotation
with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
@@ -9,7 +9,10 @@ from pathlib import Path
import pytest
from esphome.components.network import require_high_performance_networking
from esphome.components.network import (
require_high_performance_networking,
require_high_performance_wifi,
)
from tests.component_tests.network import sdkconfig_option
@@ -52,3 +55,15 @@ def test_lwip_tier(
assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale
# Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded
assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers
def test_wifi_only_request_leaves_lwip_alone(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A WiFi-only request sizes the driver buffers but not the TCP windows."""
require_high_performance_wifi()
generate_main(component_config_path("high_perf_wifi_only.yaml"))
assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") is None
assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == 64
assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == 64
@@ -121,7 +121,7 @@ struct Rig {
DaikinClimate climate;
Rig() {
// All instances share one preference key; clear it so setup starts from defaults
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
+1 -1
View File
@@ -143,7 +143,7 @@ struct Fixture {
/// Pass keep_preferences to restore what an earlier fixture saved
explicit Fixture(bool keep_preferences = false) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (!keep_preferences && global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does; the boost timer needs its scheduler
@@ -0,0 +1,16 @@
esphome:
on_boot:
then:
- audio_dac.mute_off:
- audio_dac.mute_on:
- audio_dac.set_volume:
volume: 50%
audio_dac:
- platform: es9038q2m_katana
i2c_id: i2c_bus
filter_shape: hybrid
bits_per_sample: 24bit
sample_rate: 96000
deemphasis: 48khz
dop: true
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
es9038q2m_katana: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
es9038q2m_katana: !include common.yaml
@@ -8,7 +8,6 @@
#include <string>
#include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h"
#include "esphome/core/preferences.h"
#ifdef USE_HOST
#include "esphome/components/host/preferences.h"
#endif
@@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test {
#ifdef USE_HOST
host::setup_preferences();
#endif
global_preferences->reset();
}
void TearDown() override {
global_preferences->reset();
if (this->saved_prefdir_.has_value()) {
setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1);
} else {
@@ -4,7 +4,6 @@
#include "esphome/components/friedrich/friedrich.h"
#include "esphome/components/remote_base/aeha_protocol.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
namespace esphome::friedrich::testing {
@@ -42,12 +41,7 @@ struct Fixture {
TestFriedrich sut;
CapturingTransmitter transmitter;
Fixture() {
// Climate tests share one preference key, so clear what an earlier test saved or setup restores it
if (global_preferences != nullptr)
global_preferences->reset();
this->sut.set_transmitter(&this->transmitter);
}
Fixture() { this->sut.set_transmitter(&this->transmitter); }
// The last transmission decoded, or an empty frame if it could not be decoded
AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); }
+4
View File
@@ -0,0 +1,4 @@
fusb302b:
id: pd
interrupt_pin: ${interrupt_pin}
request_voltage: 20
@@ -0,0 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
fusb302b: !include common.yaml
+7 -1
View File
@@ -2,6 +2,7 @@
#include "esphome/components/logger/logger.h"
#include "esphome/core/application.h"
#include "esphome/core/preferences.h"
/*
This special main.cpp replaces the default one.
@@ -22,7 +23,12 @@ void original_setup() {
// The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python
class ResetAppListener : public ::testing::EmptyTestEventListener {
void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; }
void OnTestStart(const ::testing::TestInfo &) override {
esphome::LoopBlockingGuard reset{nullptr, nullptr, 0};
// Entities of the same kind share preference keys, so a value an earlier test saved would be restored
if (esphome::global_preferences != nullptr)
esphome::global_preferences->reset();
}
};
void setup() {
@@ -3,5 +3,4 @@ substitutions:
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
<<: !include common.yaml
micro_wake_word: !include common.yaml
@@ -6,4 +6,5 @@ packages:
micro_wake_word: !include common.yaml
micro_wake_word:
task_core: 1
prefer_internal_ram: true
@@ -81,7 +81,7 @@ struct Fixture {
MistralIR climate;
Fixture() {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does
@@ -69,7 +69,7 @@ struct Fixture {
PDPioneerIR climate;
Fixture() {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does
@@ -7,7 +7,6 @@
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/components/whirlpool/whirlpool.h"
#include "esphome/core/preferences.h"
namespace esphome::whirlpool::testing {
@@ -122,9 +121,6 @@ struct Fixture {
WhirlpoolClimate climate;
explicit Fixture(Model model) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
if (global_preferences != nullptr)
global_preferences->reset();
this->climate.set_model(model);
this->climate.set_transmitter(&this->transmitter);
this->climate.setup();
@@ -0,0 +1,12 @@
wifi:
dpp:
channels: [1, 6, 11]
device_info: ESPHome
timeout: 30s
on_dpp_uri:
- logger.log:
format: "DPP URI %s"
args: [uri]
packages:
wifi: !include common.yaml
@@ -0,0 +1,8 @@
wifi:
ap:
ssid: WISP AP
password: wisp12345678
ap_mode: wisp
packages:
wifi: !include common.yaml
@@ -2,6 +2,9 @@ wifi:
min_auth_mode: WPA2
post_connect_roaming: true
phy_mode: 11G
ap:
ssid: Fallback AP
password: fallback_password
packages:
- !include common.yaml
+7 -1
View File
@@ -1 +1,7 @@
<<: !include common.yaml
wifi:
ap:
ssid: Fallback AP
password: fallback_password
packages:
wifi: !include common.yaml
+7 -1
View File
@@ -1 +1,7 @@
<<: !include common.yaml
wifi:
ap:
ssid: Fallback AP
password: fallback_password
packages:
wifi: !include common.yaml
@@ -0,0 +1,6 @@
# A bare dpp: takes every default
wifi:
dpp:
packages:
wifi: !include common.yaml
@@ -0,0 +1,16 @@
# A second DNS server needs ESP-IDF 6.0; config-only, a build would re-use the 5.x build dir
esp32:
framework:
version: 6.0.1
wifi:
ap:
ssid: WISP AP
password: wisp12345678
ap_mode: wisp
dns_servers:
- 8.8.8.8
- 8.8.4.4
packages:
wifi: !include common.yaml
@@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = {
"sendspin": 6,
},
"WizardCapabilities": {"configured": 1},
"SendspinCapabilities": {"feature_flags": 1},
"SendspinCapabilities": {"feature_flags": 1, "client_id": 2},
"BluetoothProxyCapabilities": {
"feature_flags": 1,
"mac_address": 2,
@@ -0,0 +1,86 @@
"""Validate inference task affinity before generating firmware."""
import pytest
from esphome.components import audio, micro_wake_word as mww
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_ESP32, KEY_TARGET_PLATFORM, KEY_VARIANT
from esphome.core import CORE
import esphome.final_validate as fv
@pytest.mark.parametrize(
"variant",
["ESP32", "ESP32S3", "ESP32P4", "ESP32H4", "ESP32S31", "ESP32S2", "ESP32C3"],
)
@pytest.mark.parametrize("core", [-1, 0, 1])
def test_task_core_matches_target(variant: str, core: int) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
CORE.data[KEY_ESP32] = {KEY_VARIANT: variant}
token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}})
try:
config = mww.CONFIG_SCHEMA({"task_core": core})
if core == 1 and variant in ("ESP32S2", "ESP32C3"):
with pytest.raises(cv.Invalid, match="only available"):
mww._validate_task_core(config)
else:
assert mww._validate_task_core(config)["task_core"] == core
finally:
fv.full_config.reset(token)
@pytest.mark.parametrize("core", [-2, 2])
def test_invalid_task_core(core: int) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
with pytest.raises(cv.Invalid):
mww.CONFIG_SCHEMA({"task_core": core})
def test_default_task_core_is_unpinned() -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
assert mww.CONFIG_SCHEMA({})["task_core"] == -1
@pytest.mark.parametrize("unicore", ["y", "n", "true", "1"])
def test_core_one_matches_freertos_unicore_setting(unicore: str) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
token = fv.full_config.set(
{
"esp32": {
"framework": {"sdkconfig_options": {"CONFIG_FREERTOS_UNICORE": unicore}}
}
}
)
try:
if unicore == "y":
with pytest.raises(cv.Invalid, match="dual-core"):
mww._validate_task_core({"task_core": 1})
else:
assert mww._validate_task_core({"task_core": 1})["task_core"] == 1
finally:
fv.full_config.reset(token)
@pytest.mark.parametrize("variant", ["ESP32S3", "ESP32C3"])
def test_task_core_final_validation(
variant: str, monkeypatch: pytest.MonkeyPatch
) -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
CORE.data[KEY_ESP32] = {KEY_VARIANT: variant}
# Isolate microphone capabilities; exercise the real final-validation chain.
monkeypatch.setattr(
audio,
"final_validate_audio_schema",
lambda *args, **kwargs: lambda config: config,
)
token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}})
try:
config = mww.CONFIG_SCHEMA({"task_core": 1})
if variant == "ESP32C3":
with pytest.raises(cv.Invalid, match="only available"):
mww.FINAL_VALIDATE_SCHEMA(config)
else:
mww.FINAL_VALIDATE_SCHEMA(config)
finally:
fv.full_config.reset(token)
+188 -1
View File
@@ -1,22 +1,43 @@
"""Tests for WiFi component public helpers."""
from ipaddress import IPv4Address
import pytest
from esphome.components.esp32 import const
from esphome.components.network import (
KEY_HIGH_PERFORMANCE_NETWORKING,
KEY_HIGH_PERFORMANCE_WIFI,
)
from esphome.components.wifi import (
CONF_AP_MODE,
CONF_AP_TIMEOUT,
CONF_DNS_SERVERS,
_validate_dpp_device_info,
check_placeholder_credentials,
final_validate as wifi_final_validate,
has_native_wifi,
variant_has_wifi,
wifi_network_ap,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
CONF_DNS1,
CONF_MANUAL_IP,
CONF_NETWORKS,
CONF_PASSWORD,
CONF_SSID,
CONF_WIFI,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLACEHOLDER_WIFI_SSID,
Platform,
)
from esphome.core import EsphomeError, Lambda
from esphome.core import CORE, EsphomeError, Lambda
import esphome.final_validate as fv
@pytest.mark.parametrize(
@@ -197,3 +218,169 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None:
"""A templated (Lambda) SSID is not a string and is skipped."""
config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}])
assert check_placeholder_credentials(config) is None
@pytest.mark.parametrize("info", ["ESPHome bench", "a\tb", " x"])
def test_dpp_device_info_rejects_whitespace(info: str) -> None:
"""The supplicant reads the text up to the first space."""
with pytest.raises(cv.Invalid, match="whitespace"):
_validate_dpp_device_info(info)
assert _validate_dpp_device_info("bench-01") == "bench-01"
@pytest.fixture
def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None:
"""An ESP32 build; the parameter overrides the framework or the ESP-IDF version."""
params = getattr(request, "param", {})
monkeypatch.setitem(
CORE.data,
KEY_CORE,
{
KEY_TARGET_PLATFORM: Platform.ESP32,
KEY_TARGET_FRAMEWORK: params.get("framework", "esp-idf"),
KEY_FRAMEWORK_VERSION: params.get("idf", cv.Version(5, 4, 0)),
},
)
def _wisp_ap(**overrides: object) -> dict:
return {CONF_AP_MODE: "WISP", CONF_PASSWORD: "wisp12345678", **overrides}
def _final_validate(config: dict, full_extra: dict | None = None) -> None:
token = fv.full_config.set({CONF_WIFI: config, **(full_extra or {})})
try:
wifi_final_validate(config)
finally:
fv.full_config.reset(token)
@pytest.mark.usefixtures("wisp_core")
@pytest.mark.parametrize(
"ap", [{CONF_AP_MODE: "WISP"}, _wisp_ap(**{CONF_PASSWORD: ""})]
)
def test_wisp_requires_ap_password(ap: dict) -> None:
"""WISP must not route upstream access through an open access point."""
with pytest.raises(cv.Invalid, match="password"):
wifi_network_ap(ap)
@pytest.mark.usefixtures("wisp_core")
def test_wisp_accepts_ap_password() -> None:
assert wifi_network_ap(_wisp_ap())[CONF_AP_MODE] == "WISP"
@pytest.mark.usefixtures("wisp_core")
def test_wisp_rejects_zero_ap_timeout() -> None:
"""Zero disables the fallback AP; the bridge would retry a failed start on every loop pass."""
with pytest.raises(cv.Invalid, match="ap_timeout"):
wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "0s"}))
wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "30s"}))
@pytest.mark.usefixtures("wisp_core")
def test_dns_servers_reject_unspecified_address() -> None:
"""0.0.0.0 would leave the clients with the access point as their resolver."""
with pytest.raises(cv.Invalid, match="0.0.0.0"):
wifi_network_ap(_wisp_ap(dns_servers=["0.0.0.0"]))
@pytest.mark.usefixtures("wisp_core")
def test_dns_servers_require_wisp() -> None:
with pytest.raises(cv.Invalid, match="dns_servers requires ap_mode: WISP"):
wifi_network_ap({CONF_DNS_SERVERS: ["1.1.1.1"]})
@pytest.mark.usefixtures("wisp_core")
def test_dns_servers_limited_to_two() -> None:
"""The access point DHCP server has a main and a backup resolver."""
with pytest.raises(cv.Invalid):
wifi_network_ap(_wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8", "9.9.9.9"]))
@pytest.mark.parametrize(
("wisp_core", "valid"),
[({"idf": cv.Version(5, 5, 5)}, False), ({"idf": cv.Version(6, 0, 1)}, True)],
indirect=["wisp_core"],
)
def test_second_dns_server_requires_idf_6(wisp_core: None, valid: bool) -> None:
ap = _wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"])
if valid:
wifi_network_ap(ap)
else:
with pytest.raises(cv.Invalid, match="6.0.0"):
wifi_network_ap(ap)
@pytest.mark.parametrize("wisp_core", [{"framework": "arduino"}], indirect=True)
def test_wisp_requires_esp_idf(wisp_core: None) -> None:
"""NAT is only available with the ESP-IDF framework."""
with pytest.raises(cv.Invalid, match="incompatible"):
wifi_network_ap(_wisp_ap())
@pytest.mark.parametrize("wisp_core", [{"idf": cv.Version(5, 0, 9)}], indirect=True)
def test_wisp_requires_idf_5_1(wisp_core: None) -> None:
"""esp_netif_napt_enable() is missing before ESP-IDF 5.1."""
with pytest.raises(cv.Invalid, match="5.1.0"):
wifi_network_ap(_wisp_ap())
@pytest.mark.usefixtures("wisp_core")
def test_wisp_requires_sta_network() -> None:
"""WISP has nothing to share without an upstream network."""
with pytest.raises(cv.Invalid, match="at least one STA network"):
_final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()})
@pytest.mark.usefixtures("wisp_core")
def test_wisp_rejects_captive_portal() -> None:
with pytest.raises(cv.Invalid, match="captive_portal"):
_final_validate(
{CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}}
)
def _manual_ip(dns1: str) -> dict:
return {CONF_DNS1: IPv4Address(dns1)}
@pytest.mark.usefixtures("wisp_core")
def test_wisp_manual_ip_needs_dns1() -> None:
"""The bridge passes the station's resolvers on, which a static address must name."""
with pytest.raises(cv.Invalid, match="dns1"):
_final_validate(
{
CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}],
CONF_AP: _wisp_ap(),
}
)
with pytest.raises(cv.Invalid, match="dns1"):
_final_validate(
{
CONF_MANUAL_IP: _manual_ip("0.0.0.0"),
CONF_NETWORKS: [{}],
CONF_AP: _wisp_ap(),
}
)
@pytest.mark.usefixtures("wisp_core")
def test_wisp_manual_ip_with_dns1_or_dns_servers() -> None:
_final_validate(
{CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("1.1.1.1")}], CONF_AP: _wisp_ap()}
)
_final_validate(
{
CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}],
CONF_AP: _wisp_ap(**{CONF_DNS_SERVERS: [IPv4Address("1.1.1.1")]}),
}
)
@pytest.mark.usefixtures("wisp_core")
def test_wisp_final_validate_accepts_sta_and_ap() -> None:
_final_validate({CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()})
# NAT needs the WiFi driver buffers, not the lwip TCP windows
assert CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI) is True
assert not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING)
+29 -1
View File
@@ -17,6 +17,8 @@ from esphome.const import (
CONF_BUILD_PATH,
CONF_DEVICES,
CONF_ESPHOME,
CONF_INCLUDES,
CONF_INCLUDES_C,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
KEY_CORE,
@@ -36,7 +38,7 @@ from esphome.core.config import (
validate_hostname,
)
from .common import load_config_from_fixture
from .common import load_config_from_fixture, load_config_from_yaml
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config"
@@ -689,6 +691,32 @@ def test_valid_include_invalid_extension(tmp_path: Path) -> None:
valid_include(str(invalid_file))
def test_includes_in_a_package_resolve_next_to_the_package(
tmp_path: Path, yaml_file: Callable[[str], Path]
) -> None:
"""A package's includes resolve next to the package file (issue #14583)."""
package_dir = tmp_path / "packages" / "helper"
package_dir.mkdir(parents=True)
(package_dir / "my_helper.h").write_text("#pragma once\n")
(package_dir / "my_c_helper.h").write_text("#pragma once\n")
(package_dir / "helper.yaml").write_text(
"esphome:\n includes:\n - my_helper.h\n includes_c:\n - my_c_helper.h\n"
)
result = load_config_from_yaml(
yaml_file,
"esphome:\n"
" name: test\n"
"host:\n"
"packages:\n"
" - !include packages/helper/helper.yaml\n",
)
assert result is not None
assert result[CONF_ESPHOME][CONF_INCLUDES] == [str(package_dir / "my_helper.h")]
assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")]
def test_valid_project_name_valid() -> None:
"""Test valid_project_name accepts valid project names."""
assert valid_project_name("esphome.my_project") == "esphome.my_project"