mirror of
https://github.com/esphome/esphome.git
synced 2026-10-19 04:32:50 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54ddd912c | ||
|
|
f3eaf698d6 | ||
|
|
f7057e1270 | ||
|
|
6edfbb594d | ||
|
|
22defcd704 | ||
|
|
0f408234d0 | ||
|
|
92e0e6db6b | ||
|
|
908c2f6778 | ||
|
|
af709ac9a5 | ||
|
|
fe57eef3f6 | ||
|
|
d0b9f195b0 | ||
|
|
eafeef7cb8 | ||
|
|
471774cdbf | ||
|
|
4df6291feb | ||
|
|
d8ca4a9460 | ||
|
|
48a0a41cce | ||
|
|
0e2c4cf5c2 | ||
|
|
ea76073d86 | ||
|
|
2bab4b9248 | ||
|
|
dcb943ded3 | ||
|
|
2c6f59738c | ||
|
|
04715fb32b | ||
|
|
3df9360876 | ||
|
|
61f3de0bc0 | ||
|
|
cd97c9a9cb | ||
|
|
525c4a0e08 | ||
|
|
c8b3bb5f77 | ||
|
|
d3668a7186 | ||
|
|
aa1516f203 | ||
|
|
439f79f0b4 | ||
|
|
e217df8d41 | ||
|
|
003698970e | ||
|
|
f8208b8fc2 | ||
|
|
18ec9dd85b | ||
|
|
f12f5d3a1f | ||
|
|
33a9439d8f | ||
|
|
c0ed06215a | ||
|
|
7235c2a8d6 | ||
|
|
07b8bb53f8 | ||
|
|
6d1cd3f62e | ||
|
|
5cbf994b28 | ||
|
|
6cf30012a5 | ||
|
|
beff1dec4f | ||
|
|
e5e92b1e22 | ||
|
|
f843cc81c6 | ||
|
|
e8e5f6366d | ||
|
|
a00982af2a | ||
|
|
e0e0e3cb8f | ||
|
|
20bda75684 | ||
|
|
8a4959eb6c | ||
|
|
d0bb1e5a98 |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -3043,12 +3046,15 @@ enum SerialProxyIdentityFlag {
|
||||
// IDs below are empty
|
||||
}
|
||||
|
||||
// The descriptor fields of a USB device as seen by the host stack
|
||||
// The descriptor fields of a USB device as seen by the host stack, and the port's index
|
||||
// among those a vendor driver exposes on the interface
|
||||
message UsbDeviceDescriptor {
|
||||
uint32 vendor_id = 1;
|
||||
uint32 product_id = 2;
|
||||
uint32 bcd_device = 3;
|
||||
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
|
||||
uint32 interface_number = 4; // bInterfaceNumber the port is bound through
|
||||
uint32 interface_class = 5; // bInterfaceClass of that interface
|
||||
uint32 port_number = 6;
|
||||
}
|
||||
|
||||
// The identity of the device behind a port. Identifies one physical device among others of
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -4175,6 +4177,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id);
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device);
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number);
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class);
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number);
|
||||
return pos;
|
||||
}
|
||||
uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
|
||||
@@ -4184,6 +4188,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) {
|
||||
size += ProtoSize::calc_uint32(1, msg.product_id);
|
||||
size += ProtoSize::calc_uint32(1, msg.bcd_device);
|
||||
size += ProtoSize::calc_uint32(1, msg.interface_number);
|
||||
size += ProtoSize::calc_uint32(1, msg.interface_class);
|
||||
size += ProtoSize::calc_uint32(1, msg.port_number);
|
||||
return size;
|
||||
}
|
||||
uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
|
||||
|
||||
@@ -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);
|
||||
@@ -4083,6 +4084,8 @@ class UsbDeviceDescriptor final : public ProtoMessage {
|
||||
uint32_t product_id{0};
|
||||
uint32_t bcd_device{0};
|
||||
uint32_t interface_number{0};
|
||||
uint32_t interface_class{0};
|
||||
uint32_t port_number{0};
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
@@ -2942,6 +2943,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
|
||||
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
|
||||
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
|
||||
dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class);
|
||||
dump_field(out, ESPHOME_PSTR("port_number"), this->port_number);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, speaker
|
||||
from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -14,9 +15,11 @@ from esphome.const import (
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_TIMEOUT,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_I2S_AUDIO_ID,
|
||||
CONF_I2S_DOUT_PIN,
|
||||
CONF_I2S_MODE,
|
||||
CONF_LEFT,
|
||||
@@ -44,6 +47,7 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase)
|
||||
|
||||
CONF_DAC_TYPE = "dac_type"
|
||||
CONF_I2S_COMM_FMT = "i2s_comm_fmt"
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
CONF_SPDIF_MODE = "spdif_mode"
|
||||
|
||||
I2SAudioSpeakerBase = i2s_audio_ns.class_(
|
||||
@@ -173,6 +177,7 @@ BASE_SCHEMA = (
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.one_of(CONF_NEVER, lower=True),
|
||||
),
|
||||
cv.Optional(CONF_KEEP_ALIVE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -235,6 +240,23 @@ def _final_validate(config: ConfigType) -> None:
|
||||
raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'")
|
||||
if config[CONF_MCLK_MULTIPLE] != 256:
|
||||
raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'")
|
||||
if config[CONF_KEEP_ALIVE]:
|
||||
raise cv.Invalid("SPDIF mode does not support 'keep_alive'")
|
||||
|
||||
if config[CONF_KEEP_ALIVE]:
|
||||
# A kept channel holds the port between sessions, so nothing else may share the bus
|
||||
bus = config[CONF_I2S_AUDIO_ID]
|
||||
full_config = fv.full_config.get()
|
||||
on_bus = sum(
|
||||
1
|
||||
for domain in (speaker.DOMAIN, MICROPHONE_DOMAIN)
|
||||
for entry in full_config.get(domain, [])
|
||||
if entry.get(CONF_I2S_AUDIO_ID) == bus
|
||||
)
|
||||
if on_bus > 1:
|
||||
raise cv.Invalid(
|
||||
"'keep_alive' needs its own I2S bus; another speaker or microphone shares this one"
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
@@ -262,6 +284,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config[CONF_TIMEOUT] != CONF_NEVER:
|
||||
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
|
||||
cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION]))
|
||||
if config[CONF_KEEP_ALIVE]:
|
||||
cg.add(var.set_keep_alive(True))
|
||||
|
||||
|
||||
# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE,
|
||||
|
||||
@@ -56,8 +56,9 @@ void I2SAudioSpeakerBase::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Speaker:\n"
|
||||
" Pin: %d\n"
|
||||
" Buffer duration: %" PRIu32,
|
||||
static_cast<int8_t>(this->dout_pin_), this->buffer_duration_ms_);
|
||||
" Buffer duration: %" PRIu32 "\n"
|
||||
" Keep alive: %s",
|
||||
static_cast<int8_t>(this->dout_pin_), this->buffer_duration_ms_, YESNO(this->keep_alive_));
|
||||
if (this->timeout_.has_value()) {
|
||||
ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value());
|
||||
}
|
||||
@@ -105,7 +106,13 @@ void I2SAudioSpeakerBase::loop() {
|
||||
vTaskDelete(this->speaker_task_handle_);
|
||||
this->speaker_task_handle_ = nullptr;
|
||||
|
||||
this->stop_i2s_driver_();
|
||||
// The task raises this bit before it exits, possibly a loop pass before TASK_STOPPED is seen, so read it
|
||||
// live here; a channel the task could not bring up is rebuilt next time rather than kept
|
||||
const bool task_failed = xEventGroupGetBits(this->event_group_) & SpeakerEventGroupBits::ERR_ESP_NO_MEM;
|
||||
if (task_failed) {
|
||||
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
|
||||
}
|
||||
this->stop_i2s_driver_(this->keep_alive_ && !task_failed);
|
||||
// ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at
|
||||
// the top of loop(): the audio source's task can raise a start at any point above, including
|
||||
// during stop_i2s_driver_(), and nothing would ever re-issue it.
|
||||
@@ -121,11 +128,6 @@ void I2SAudioSpeakerBase::loop() {
|
||||
this->state_ = speaker::STATE_STOPPED;
|
||||
}
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
|
||||
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
}
|
||||
|
||||
// Handle the speaker's state
|
||||
switch (this->state_) {
|
||||
case speaker::STATE_STARTING:
|
||||
@@ -133,8 +135,8 @@ void I2SAudioSpeakerBase::loop() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Still starting up or winding down from a previous run
|
||||
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
|
||||
// Still winding down from a previous run
|
||||
if (this->speaker_task_handle_ != nullptr) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -154,7 +156,7 @@ void I2SAudioSpeakerBase::loop() {
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error(1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
break;
|
||||
case speaker::STATE_RUNNING: // Intentional fallthrough
|
||||
@@ -270,31 +272,25 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
|
||||
err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to initialize I2S channel");
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
this->delete_channel_();
|
||||
this->parent_->unlock();
|
||||
return err;
|
||||
}
|
||||
|
||||
// Both queues are drained by the speaker task before it preloads, whether the channel is new or kept
|
||||
if (this->i2s_event_queue_ == nullptr) {
|
||||
this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t));
|
||||
} else {
|
||||
// Reset queue to clear any stale events from previous task
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
}
|
||||
|
||||
// Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue
|
||||
// so a fully-saturated DMA pipeline cannot overflow either side before drain.
|
||||
if (this->write_records_queue_ == nullptr) {
|
||||
this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t));
|
||||
} else {
|
||||
xQueueReset(this->write_records_queue_);
|
||||
}
|
||||
|
||||
if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)");
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
this->delete_channel_();
|
||||
this->parent_->unlock();
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
@@ -302,22 +298,30 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void I2SAudioSpeakerBase::stop_i2s_driver_() {
|
||||
void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) {
|
||||
if (this->tx_handle_ != nullptr) {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
|
||||
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
|
||||
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
|
||||
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
|
||||
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
|
||||
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
|
||||
this->park_dout_pin_();
|
||||
// A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and
|
||||
// clock reconfiguration is the audible pop
|
||||
if (!keep_channel) {
|
||||
this->delete_channel_();
|
||||
}
|
||||
}
|
||||
this->parent_->unlock();
|
||||
}
|
||||
|
||||
void I2SAudioSpeakerBase::delete_channel_() {
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
|
||||
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
|
||||
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
|
||||
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
|
||||
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
|
||||
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
|
||||
this->park_dout_pin_();
|
||||
}
|
||||
|
||||
bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
|
||||
|
||||
void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; }
|
||||
void set_timeout(uint32_t ms) { this->timeout_ = ms; }
|
||||
void set_keep_alive(bool keep_alive) { this->keep_alive_ = keep_alive; }
|
||||
void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; }
|
||||
|
||||
/// @brief Get the I2S TX channel handle
|
||||
@@ -126,12 +127,15 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
|
||||
esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg,
|
||||
size_t event_queue_size);
|
||||
|
||||
/// @brief Stops the I2S driver and unlocks the I2S port
|
||||
void stop_i2s_driver_();
|
||||
/// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated
|
||||
void stop_i2s_driver_(bool keep_channel);
|
||||
|
||||
/// @brief Detaches dout from the I2S signal and drives it low
|
||||
void park_dout_pin_();
|
||||
|
||||
/// @brief Deletes the I2S channel and parks dout; the caller holds the bus lock
|
||||
void delete_channel_();
|
||||
|
||||
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
|
||||
virtual void on_task_stopped() {}
|
||||
|
||||
@@ -177,6 +181,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
|
||||
optional<uint32_t> timeout_;
|
||||
|
||||
bool pause_state_{false};
|
||||
bool keep_alive_{false};
|
||||
|
||||
// Smooths software gain changes. The main loop posts targets, the speaker task processes;
|
||||
// GainRamp's mailbox makes that safe. The main loop is the only poster.
|
||||
|
||||
@@ -75,6 +75,9 @@ void I2SAudioSpeaker::dump_config() {
|
||||
|
||||
void I2SAudioSpeaker::run_speaker_task() {
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING);
|
||||
// Stale records from the previous run would pair with the wrong DMA events
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
xQueueReset(this->write_records_queue_);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
@@ -389,8 +392,6 @@ void I2SAudioSpeaker::run_speaker_task() {
|
||||
}
|
||||
|
||||
esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) {
|
||||
this->current_stream_info_ = audio_stream_info;
|
||||
|
||||
if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT
|
||||
// Can't reconfigure I2S bus, so the sample rate must match the configured value
|
||||
ESP_LOGE(TAG, "Incompatible stream settings");
|
||||
@@ -428,6 +429,16 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
// Reuse a kept channel while the stream format matches
|
||||
if (this->tx_handle_ != nullptr) {
|
||||
if (this->current_stream_info_ == audio_stream_info) {
|
||||
return ESP_OK;
|
||||
}
|
||||
this->delete_channel_();
|
||||
}
|
||||
|
||||
this->current_stream_info_ = audio_stream_info;
|
||||
|
||||
// The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info.
|
||||
uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S31,
|
||||
add_idf_sdkconfig_option,
|
||||
get_esp32_variant,
|
||||
idf_version,
|
||||
require_usb_serial_jtag_secondary,
|
||||
require_vfs_termios,
|
||||
)
|
||||
@@ -482,6 +483,16 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG:
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", True)
|
||||
cg.add_define("USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG")
|
||||
# Log V2 formats centrally in esp_log() (~4KB flash, ~180B RAM). 6.1
|
||||
# adds CONSTRAINED_ENV_SAFE=n, keeping early/DRAM logs on
|
||||
# esp_rom_printf instead of pulling ~1.2KB esp_rom_vprintf into IRAM.
|
||||
if idf_version() >= cv.Version(6, 1, 0):
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_1", False)
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_2", True)
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_API_CONSTRAINED_ENV_SAFE", False)
|
||||
cg.add_define("USE_ESP32_LOG_V2")
|
||||
# --wrap: a strong definition collides; see logger_esp32.cpp
|
||||
cg.add_build_flag("-Wl,--wrap=esp_log_format")
|
||||
# Define platform support flags for components that need auto-detection
|
||||
try:
|
||||
uart_selection(USB_SERIAL_JTAG)
|
||||
|
||||
@@ -174,4 +174,66 @@ void log_crash_to_client() {
|
||||
#endif
|
||||
|
||||
} // namespace esphome::logger
|
||||
|
||||
// The version guard keeps builds that define USE_ESP32_LOG_V2 unconditionally (static analysis) off
|
||||
// IDF private headers that only 6.1 has
|
||||
#if defined(USE_ESP32_LOG_V2) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
|
||||
#include <esp_private/log_message.h>
|
||||
#include <esp_log_write.h>
|
||||
#include <esp_rom_sys.h>
|
||||
|
||||
namespace esphome::logger {
|
||||
|
||||
// IDF levels NONE=0 E=1 W=2 I=3 D=4 V=5; ESPHome inserts CONFIG at 4
|
||||
static inline uint8_t idf_to_esphome_level(uint8_t idf_level) {
|
||||
if (idf_level <= 3)
|
||||
return idf_level;
|
||||
return idf_level >= 5 ? ESPHOME_LOG_LEVEL_VERBOSE : ESPHOME_LOG_LEVEL_DEBUG;
|
||||
}
|
||||
|
||||
// Console output without the logger hook: early boot and constrained env
|
||||
// (fwrite locks crash during PHY init on USB JTAG). Cold path.
|
||||
static void __attribute__((noinline)) esp_log_format_direct(esp_log_msg_t *message) {
|
||||
// Constrained-env stacks are small; enough for IDF's own one-liners
|
||||
char stack_buf[256];
|
||||
LogBuffer buf{stack_buf, sizeof(stack_buf)};
|
||||
buf.write_header(idf_to_esphome_level(message->config.opts.log_level), message->tag ? message->tag : "esp-idf", 0,
|
||||
nullptr);
|
||||
buf.format_body(message->format, message->args);
|
||||
esp_rom_printf("%s\n", stack_buf);
|
||||
}
|
||||
|
||||
} // namespace esphome::logger
|
||||
|
||||
extern "C" {
|
||||
// Replaces liblog's formatter, which calls the vprintf hook 3x per message
|
||||
// (header, body, newline). --wrap because a strong definition collides:
|
||||
// liblog resolves the symbol within its own archive. Not IRAM_ATTR: cache-off
|
||||
// callers bypass esp_log() under CONSTRAINED_ENV_SAFE=n.
|
||||
void __wrap_esp_log_format(esp_log_msg_t *message) { // NOLINT
|
||||
extern vprintf_like_t esp_log_vprint_func;
|
||||
if (esp_log_vprint_func == &esphome::esp_idf_log_vprintf_) [[likely]] {
|
||||
if (message->config.opts.constrained_env) [[unlikely]] {
|
||||
esphome::logger::esp_log_format_direct(message);
|
||||
return;
|
||||
}
|
||||
// Call the logger directly with V2's separate tag and severity so lines
|
||||
// render as e.g. "[E][wifi]:"; the hook signature cannot carry them.
|
||||
// global_logger is set before the hook is installed (pre_setup above).
|
||||
esphome::logger::global_logger->log_vprintf_(esphome::logger::idf_to_esphome_level(message->config.opts.log_level),
|
||||
message->tag ? message->tag : "esp-idf", 0, message->format,
|
||||
message->args);
|
||||
return;
|
||||
}
|
||||
extern int vprintf(const char *, __gnuc_va_list); // NOLINT
|
||||
if (esp_log_vprint_func == &vprintf || message->config.opts.constrained_env) {
|
||||
// No hook yet (early boot) or constrained env: direct console output
|
||||
esphome::logger::esp_log_format_direct(message);
|
||||
return;
|
||||
}
|
||||
// Custom hook via esp_log_set_vprintf: forward the body as-is
|
||||
esp_log_vprint_func(message->format, message->args);
|
||||
}
|
||||
} // extern "C"
|
||||
#endif // USE_ESP32_LOG_V2 && IDF >= 6.1
|
||||
#endif
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "lwip/dns.h"
|
||||
#include "lwip/err.h"
|
||||
#include "mqtt_component.h"
|
||||
#include "mqtt_topic_match.h"
|
||||
|
||||
#ifdef USE_API
|
||||
#include "esphome/components/api/api_server.h"
|
||||
@@ -611,63 +612,6 @@ void MQTTClientComponent::disable() {
|
||||
this->on_shutdown();
|
||||
}
|
||||
|
||||
/** Check if the message topic matches the given subscription topic
|
||||
*
|
||||
* INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings.
|
||||
*
|
||||
* @param message The message topic that was received from the MQTT server. Note: this must not contain
|
||||
* wildcard characters as mandated by the MQTT spec.
|
||||
* @param subscription The subscription topic we are matching against.
|
||||
* @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$".
|
||||
* @param past_separator Are we past the first '/' topic separator.
|
||||
* @return true if the subscription topic matches the message topic, false otherwise.
|
||||
*/
|
||||
static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
|
||||
// Reached end of both strings at the same time, this means we have a successful match
|
||||
if (*message == '\0' && *subscription == '\0')
|
||||
return true;
|
||||
|
||||
// Either the message or the subscribe are at the end. This means they don't match.
|
||||
if (*message == '\0' || *subscription == '\0')
|
||||
return false;
|
||||
|
||||
bool do_wildcards = is_normal || past_separator;
|
||||
|
||||
if (*subscription == '+' && do_wildcards) {
|
||||
// single level wildcard
|
||||
// consume + from subscription
|
||||
subscription++;
|
||||
// consume everything from message until '/' found or end of string
|
||||
while (*message != '\0' && *message != '/') {
|
||||
message++;
|
||||
}
|
||||
// after this, both pointers will point to a '/' or to the end of the string
|
||||
|
||||
return topic_match(message, subscription, is_normal, true);
|
||||
}
|
||||
|
||||
if (*subscription == '#' && do_wildcards) {
|
||||
// multilevel wildcard - MQTT mandates that this must be at end of subscribe topic
|
||||
return true;
|
||||
}
|
||||
|
||||
// this handles '/' and normal characters at the same time.
|
||||
if (*message != *subscription)
|
||||
return false;
|
||||
|
||||
past_separator = past_separator || *subscription == '/';
|
||||
|
||||
// consume characters
|
||||
subscription++;
|
||||
message++;
|
||||
|
||||
return topic_match(message, subscription, is_normal, past_separator);
|
||||
}
|
||||
|
||||
static bool topic_match(const char *message, const char *subscription) {
|
||||
return topic_match(message, subscription, *message != '\0' && *message != '$', false);
|
||||
}
|
||||
|
||||
void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) {
|
||||
#ifdef USE_ESP8266
|
||||
// IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
namespace esphome::mqtt {
|
||||
|
||||
/// True if the message topic matches the subscription, with + and # wildcards.
|
||||
/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics.
|
||||
inline bool topic_match(const char *message, const char *subscription) {
|
||||
// First-level wildcards do not match topics that begin with '$'
|
||||
bool wildcards = *message != '$';
|
||||
while (*message != '\0' && *subscription != '\0') {
|
||||
if (wildcards && *subscription == '#')
|
||||
return true;
|
||||
if (wildcards && *subscription == '+') {
|
||||
subscription++;
|
||||
while (*message != '\0' && *message != '/')
|
||||
message++;
|
||||
continue;
|
||||
}
|
||||
if (*message != *subscription)
|
||||
return false;
|
||||
wildcards |= *subscription == '/';
|
||||
message++;
|
||||
subscription++;
|
||||
}
|
||||
return *message == *subscription; // a match only if both ended
|
||||
}
|
||||
|
||||
} // namespace esphome::mqtt
|
||||
@@ -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).
|
||||
|
||||
@@ -38,6 +38,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
|
||||
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
|
||||
.hash_id = NOISE_HASH_SHA256,
|
||||
.hybrid_id = NOISE_DH_NONE,
|
||||
.reserved = {},
|
||||
};
|
||||
|
||||
int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -418,6 +418,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api:
|
||||
msg.usb.product_id = scratch.product_id;
|
||||
msg.usb.bcd_device = scratch.bcd_device;
|
||||
msg.usb.interface_number = this->usb_channel_->get_interface_number();
|
||||
msg.usb.interface_class = this->usb_channel_->get_interface_class();
|
||||
msg.usb.port_number = this->usb_channel_->get_port_number();
|
||||
msg.manufacturer = StringRef(scratch.manufacturer);
|
||||
msg.product = StringRef(scratch.product);
|
||||
msg.serial_number = StringRef(scratch.serial_number);
|
||||
|
||||
@@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<CdcEps> USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) {
|
||||
auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl);
|
||||
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare
|
||||
for (auto &cdc_dev : result) {
|
||||
cdc_dev.interrupt_interface_number = 0xFF;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace esphome::usb_uart
|
||||
|
||||
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
|
||||
|
||||
@@ -88,11 +88,11 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
|
||||
ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed");
|
||||
continue;
|
||||
}
|
||||
if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) {
|
||||
cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}});
|
||||
} else {
|
||||
cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}});
|
||||
}
|
||||
CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN
|
||||
? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}
|
||||
: CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber};
|
||||
eps.bulk_interface_class = data_desc->bInterfaceClass;
|
||||
cdc_devs.push_back(eps);
|
||||
}
|
||||
return cdc_devs;
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ static optional<CdcEps> get_uart(const usb_config_desc_t *config_desc, uint8_t i
|
||||
}
|
||||
|
||||
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
|
||||
eps.bulk_interface_class = intf_desc->bInterfaceClass;
|
||||
return eps;
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,10 @@ std::vector<CdcEps> USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev
|
||||
}
|
||||
|
||||
if (in_ep && out_ep) {
|
||||
cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber});
|
||||
CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber};
|
||||
eps.bulk_interface_class = intf->bInterfaceClass;
|
||||
eps.interrupt_interface_class = intf->bInterfaceClass;
|
||||
cdc_devs.push_back(eps);
|
||||
break; // PL2303 is single-port
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,14 +41,17 @@ static optional<CdcEps> get_cdc(const usb_config_desc_t *config_desc, uint8_t in
|
||||
if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) {
|
||||
eps.notify_ep = ep;
|
||||
eps.interrupt_interface_number = intf_desc->bInterfaceNumber;
|
||||
eps.interrupt_interface_class = intf_desc->bInterfaceClass;
|
||||
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN &&
|
||||
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
|
||||
eps.in_ep = ep;
|
||||
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
|
||||
eps.bulk_interface_class = intf_desc->bInterfaceClass;
|
||||
} else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) &&
|
||||
(eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) {
|
||||
eps.out_ep = ep;
|
||||
eps.bulk_interface_number = intf_desc->bInterfaceNumber;
|
||||
eps.bulk_interface_class = intf_desc->bInterfaceClass;
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes);
|
||||
continue;
|
||||
@@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() {
|
||||
if (channel->cdc_dev_.interrupt_interface_number != 0xFF &&
|
||||
channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) {
|
||||
if (!this->claim_comm_interface_) {
|
||||
ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)",
|
||||
channel->cdc_dev_.interrupt_interface_number);
|
||||
ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number);
|
||||
} else {
|
||||
auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_,
|
||||
channel->cdc_dev_.interrupt_interface_number, 0);
|
||||
|
||||
@@ -40,6 +40,9 @@ struct CdcEps {
|
||||
// must remain valid even when the interface itself is not claimed.
|
||||
uint8_t interrupt_interface_number{0xFF};
|
||||
bool interrupt_interface_claimed{false};
|
||||
// bInterfaceClass of each interface above, which decides how a host binds the port
|
||||
uint8_t bulk_interface_class{0};
|
||||
uint8_t interrupt_interface_class{0};
|
||||
};
|
||||
|
||||
enum CH34xChipType : uint8_t {
|
||||
@@ -187,6 +190,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
|
||||
: this->cdc_dev_.bulk_interface_number;
|
||||
}
|
||||
|
||||
/// bInterfaceClass of the interface `get_interface_number()` names
|
||||
uint8_t get_interface_class() const {
|
||||
return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class
|
||||
: this->cdc_dev_.bulk_interface_class;
|
||||
}
|
||||
|
||||
/// Index of this port among the ports on its interface. Every driver here exposes one
|
||||
/// port per interface; a multiplexed driver would index its channels.
|
||||
uint8_t get_port_number() const { return 0; }
|
||||
|
||||
protected:
|
||||
// Not directly instantiable; construct a concrete channel type instead.
|
||||
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
|
||||
@@ -318,13 +331,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm {
|
||||
};
|
||||
class USBUartTypeCH34X : public USBUartTypeCdcAcm {
|
||||
public:
|
||||
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {}
|
||||
USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {
|
||||
// ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The
|
||||
// comm interface is left in place unclaimed: on the CDC-style chips it is still the
|
||||
// interface a host binds, and so the one the port is identified by.
|
||||
this->claim_comm_interface_ = false;
|
||||
}
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
|
||||
bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override;
|
||||
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
|
||||
|
||||
private:
|
||||
CH34xChipType chiptype_{CHIP_UNKNOWN};
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), ¤t) != 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
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
#ifndef USE_HOST // web_server, captive_portal: no ESPAsyncWebServer on host
|
||||
#define USE_CAPTIVE_PORTAL
|
||||
#endif
|
||||
#define USE_ESP32_LOG_V2
|
||||
#define USE_WIFI_SCAN_RESULTS_LOCK
|
||||
#define USE_ESP32_BLE
|
||||
#define USE_ESP32_BLE_MAX_CONNECTIONS 3
|
||||
@@ -496,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
|
||||
@@ -562,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-3
@@ -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
|
||||
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
|
||||
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.12.4 # native esp-idf toolchain global cache dir
|
||||
platformdirs==4.13.0 # native esp-idf toolchain global cache dir
|
||||
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
|
||||
filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
pyparsing >= 3.3.3
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pylint==4.1.2
|
||||
pylint==4.1.3
|
||||
flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: i2s-test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2s_audio:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
dac_type: external
|
||||
i2s_dout_pin: GPIO12
|
||||
keep_alive: true
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
esphome:
|
||||
name: i2s-test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2s_audio:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
dac_type: external
|
||||
i2s_dout_pin: GPIO12
|
||||
keep_alive: true
|
||||
- platform: i2s_audio
|
||||
dac_type: external
|
||||
i2s_dout_pin: GPIO13
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
esphome:
|
||||
name: i2s-test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2s_audio:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
dac_type: external
|
||||
i2s_dout_pin: GPIO12
|
||||
keep_alive: true
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
i2s_din_pin: GPIO34
|
||||
adc_type: external
|
||||
pdm: false
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
esphome:
|
||||
name: i2s-test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2s_audio:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
dac_type: external
|
||||
i2s_dout_pin: GPIO12
|
||||
spdif_mode: true
|
||||
use_apll: true
|
||||
sample_rate: 48000
|
||||
channel: stereo
|
||||
keep_alive: true
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Exercise the I2S speaker keep_alive validation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_keep_alive_generates_setter(
|
||||
generate_main: Callable[[Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
source = generate_main(component_config_path("keep_alive.yaml"))
|
||||
assert "set_keep_alive(true)" in source
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "message"),
|
||||
[
|
||||
("keep_alive_shared_bus.yaml", "needs its own I2S bus"),
|
||||
("keep_alive_shared_mic.yaml", "needs its own I2S bus"),
|
||||
("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"),
|
||||
],
|
||||
)
|
||||
def test_keep_alive_rejected(
|
||||
component_config_path: Callable[[str], Path],
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
config_file: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
CORE.config_path = component_config_path(config_file)
|
||||
assert read_config({}) is None
|
||||
assert message in capsys.readouterr().out
|
||||
@@ -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);
|
||||
|
||||
@@ -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{}); }
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
logger: !include common-default_uart.yaml
|
||||
|
||||
# IDF 6.1 is where Log V2 and the esp_log_format wrap are enabled; the default IDF has no V2
|
||||
esp32:
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: 6.1.0
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include "esphome/components/mqtt/mqtt_topic_match.h"
|
||||
|
||||
namespace esphome::mqtt::testing {
|
||||
|
||||
// The previous recursive implementation, kept to check the loop matches it
|
||||
static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) {
|
||||
if (*message == '\0' && *subscription == '\0')
|
||||
return true;
|
||||
if (*message == '\0' || *subscription == '\0')
|
||||
return false;
|
||||
bool do_wildcards = is_normal || past_separator;
|
||||
if (*subscription == '+' && do_wildcards) {
|
||||
subscription++;
|
||||
while (*message != '\0' && *message != '/')
|
||||
message++;
|
||||
return reference_match(message, subscription, is_normal, true);
|
||||
}
|
||||
if (*subscription == '#' && do_wildcards)
|
||||
return true;
|
||||
if (*message != *subscription)
|
||||
return false;
|
||||
past_separator = past_separator || *subscription == '/';
|
||||
return reference_match(message + 1, subscription + 1, is_normal, past_separator);
|
||||
}
|
||||
|
||||
static bool reference_match(const char *message, const char *subscription) {
|
||||
return reference_match(message, subscription, *message != '\0' && *message != '$', false);
|
||||
}
|
||||
|
||||
TEST(MQTTTopicMatch, Exact) {
|
||||
EXPECT_TRUE(topic_match("a/b/c", "a/b/c"));
|
||||
EXPECT_FALSE(topic_match("a/b/c", "a/b"));
|
||||
EXPECT_FALSE(topic_match("a/b", "a/b/c"));
|
||||
EXPECT_FALSE(topic_match("a/b/c", "a/b/d"));
|
||||
}
|
||||
|
||||
TEST(MQTTTopicMatch, SingleLevelWildcard) {
|
||||
EXPECT_TRUE(topic_match("a/b/c", "a/+/c"));
|
||||
EXPECT_TRUE(topic_match("a/b/c", "+/+/+"));
|
||||
EXPECT_TRUE(topic_match("a//c", "a/+/c"));
|
||||
EXPECT_FALSE(topic_match("a/b/c", "a/+"));
|
||||
EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c"));
|
||||
}
|
||||
|
||||
TEST(MQTTTopicMatch, MultiLevelWildcard) {
|
||||
EXPECT_TRUE(topic_match("a/b/c", "a/#"));
|
||||
EXPECT_TRUE(topic_match("a/b/c", "#"));
|
||||
EXPECT_TRUE(topic_match("a/b/c", "a/+/#"));
|
||||
EXPECT_FALSE(topic_match("b/c", "a/#"));
|
||||
}
|
||||
|
||||
TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) {
|
||||
EXPECT_FALSE(topic_match("$SYS/broker", "#"));
|
||||
EXPECT_FALSE(topic_match("$SYS/broker", "+/broker"));
|
||||
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#"));
|
||||
EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+"));
|
||||
}
|
||||
|
||||
TEST(MQTTTopicMatch, EdgeCases) {
|
||||
EXPECT_TRUE(topic_match("", ""));
|
||||
EXPECT_FALSE(topic_match("a", ""));
|
||||
// Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level
|
||||
EXPECT_FALSE(topic_match("a/b", "a/b/#"));
|
||||
EXPECT_FALSE(topic_match("a/", "a/+"));
|
||||
EXPECT_TRUE(topic_match("a/$x", "a/+"));
|
||||
}
|
||||
|
||||
// Recursion overflowed the ESP32 loop stack at about 250 characters
|
||||
TEST(MQTTTopicMatch, LongTopic) {
|
||||
std::string topic(4096, 'x');
|
||||
topic[100] = '/';
|
||||
EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str()));
|
||||
std::string other = topic;
|
||||
other.back() = 'y';
|
||||
EXPECT_FALSE(topic_match(topic.c_str(), other.c_str()));
|
||||
}
|
||||
|
||||
TEST(MQTTTopicMatch, MatchesPreviousImplementation) {
|
||||
// Small alphabet so wildcards, separators and '$' meet often
|
||||
static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'};
|
||||
static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'};
|
||||
std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
|
||||
auto random_string = [&rng](const char *chars, size_t count) {
|
||||
std::string s(rng() % 8, ' ');
|
||||
for (char &c : s)
|
||||
c = chars[rng() % count];
|
||||
return s;
|
||||
};
|
||||
for (int i = 0; i < 20000; i++) {
|
||||
std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS));
|
||||
std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS));
|
||||
ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()),
|
||||
reference_match(message.c_str(), subscription.c_str()))
|
||||
<< "message='" << message << "' subscription='" << subscription << "'";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::mqtt::testing
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# The harness gives a multi-instance component an empty list and never calls its to_code,
|
||||
# so take a single empty config instead and emit what the proxy messages and handlers
|
||||
# need. No proxy is configured, so the count is 0, which keeps the capability messages as
|
||||
# the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only.
|
||||
manifest.multi_conf = False
|
||||
|
||||
async def to_code_testing(config):
|
||||
cg.add_define("USE_SERIAL_PROXY")
|
||||
cg.add_define("SERIAL_PROXY_COUNT", 0)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,51 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/api/api_buffer.h"
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/api/proto.h"
|
||||
|
||||
namespace esphome::api::testing {
|
||||
|
||||
// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check.
|
||||
static std::vector<uint8_t> encode(const UsbDeviceDescriptor &msg) {
|
||||
const uint32_t size = msg.calculate_size();
|
||||
APIBuffer buf;
|
||||
EXPECT_TRUE(buf.resize(size == 0 ? 1 : size));
|
||||
uint8_t *pos = buf.data();
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *proto_debug_end_ = buf.data() + buf.size();
|
||||
#endif
|
||||
uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG);
|
||||
EXPECT_EQ(static_cast<uint32_t>(end - buf.data()), size);
|
||||
return {buf.data(), end};
|
||||
}
|
||||
|
||||
TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) {
|
||||
UsbDeviceDescriptor msg;
|
||||
msg.vendor_id = 0x10C4;
|
||||
msg.product_id = 0xEA60;
|
||||
msg.bcd_device = 0x0100;
|
||||
msg.interface_number = 1;
|
||||
msg.interface_class = 0xFF;
|
||||
msg.port_number = 2;
|
||||
|
||||
const std::vector<uint8_t> expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80,
|
||||
0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02};
|
||||
EXPECT_EQ(encode(msg), expected);
|
||||
}
|
||||
|
||||
TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) {
|
||||
// The new fields are additive with zero defaults, so an old client sees the message it always did
|
||||
UsbDeviceDescriptor msg;
|
||||
msg.vendor_id = 0x303A;
|
||||
msg.product_id = 0x1001;
|
||||
msg.interface_number = 1;
|
||||
|
||||
const std::vector<uint8_t> expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01};
|
||||
EXPECT_EQ(encode(msg), expected);
|
||||
}
|
||||
|
||||
} // namespace esphome::api::testing
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
<<: !include common.yaml
|
||||
wifi:
|
||||
ap:
|
||||
ssid: Fallback AP
|
||||
password: fallback_password
|
||||
|
||||
packages:
|
||||
wifi: !include common.yaml
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user