mirror of
https://github.com/esphome/esphome.git
synced 2026-02-28 01:44:20 -07:00
Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy
This commit is contained in:
@@ -1 +1 @@
|
||||
5eb1e5852765114ad06533220d3160b6c23f5ccefc4de41828699de5dfff5ad6
|
||||
b97e16a84153b2a4cfc51137cd6121db3c32374504b2bea55144413b3e573052
|
||||
|
||||
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -6,8 +6,9 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Developer breaking change (an API change that could break external components)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
- [ ] Code quality improvements to existing code or addition of tests
|
||||
- [ ] Other
|
||||
|
||||
|
||||
1
.github/scripts/auto-label-pr/constants.js
vendored
1
.github/scripts/auto-label-pr/constants.js
vendored
@@ -27,6 +27,7 @@ module.exports = {
|
||||
'new-feature',
|
||||
'breaking-change',
|
||||
'developer-breaking-change',
|
||||
'undocumented-api-change',
|
||||
'code-quality',
|
||||
'deprecated-component'
|
||||
],
|
||||
|
||||
1
.github/scripts/auto-label-pr/detectors.js
vendored
1
.github/scripts/auto-label-pr/detectors.js
vendored
@@ -238,6 +238,7 @@ async function detectPRTemplateCheckboxes(context) {
|
||||
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
|
||||
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
|
||||
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
|
||||
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
|
||||
{ pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' }
|
||||
];
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.2
|
||||
rev: v0.15.3
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -993,6 +993,7 @@ enum ClimateAction {
|
||||
CLIMATE_ACTION_IDLE = 4;
|
||||
CLIMATE_ACTION_DRYING = 5;
|
||||
CLIMATE_ACTION_FAN = 6;
|
||||
CLIMATE_ACTION_DEFROSTING = 7;
|
||||
}
|
||||
enum ClimatePreset {
|
||||
CLIMATE_PRESET_NONE = 0;
|
||||
|
||||
@@ -116,6 +116,7 @@ enum ClimateAction : uint32_t {
|
||||
CLIMATE_ACTION_IDLE = 4,
|
||||
CLIMATE_ACTION_DRYING = 5,
|
||||
CLIMATE_ACTION_FAN = 6,
|
||||
CLIMATE_ACTION_DEFROSTING = 7,
|
||||
};
|
||||
enum ClimatePreset : uint32_t {
|
||||
CLIMATE_PRESET_NONE = 0,
|
||||
|
||||
@@ -321,6 +321,8 @@ template<> const char *proto_enum_to_string<enums::ClimateAction>(enums::Climate
|
||||
return "CLIMATE_ACTION_DRYING";
|
||||
case enums::CLIMATE_ACTION_FAN:
|
||||
return "CLIMATE_ACTION_FAN";
|
||||
case enums::CLIMATE_ACTION_DEFROSTING:
|
||||
return "CLIMATE_ACTION_DEFROSTING";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class APIConnection;
|
||||
return this->client_->schedule_message_(entity, ResponseType::MESSAGE_TYPE, ResponseType::ESTIMATED_SIZE); \
|
||||
}
|
||||
|
||||
class ListEntitiesIterator : public ComponentIterator {
|
||||
class ListEntitiesIterator final : public ComponentIterator {
|
||||
public:
|
||||
ListEntitiesIterator(APIConnection *client);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
|
||||
@@ -16,7 +16,7 @@ class APIConnection;
|
||||
return this->client_->send_##entity_type##_state(entity); \
|
||||
}
|
||||
|
||||
class InitialStateIterator : public ComponentIterator {
|
||||
class InitialStateIterator final : public ComponentIterator {
|
||||
public:
|
||||
InitialStateIterator(APIConnection *client);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
namespace esphome::captive_portal {
|
||||
|
||||
#ifdef USE_CAPTIVE_PORTAL_GZIP
|
||||
const uint8_t INDEX_GZ[] PROGMEM = {
|
||||
constexpr uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e,
|
||||
0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36,
|
||||
0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf,
|
||||
@@ -86,7 +86,7 @@ const uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00};
|
||||
|
||||
#else // Brotli (default, smaller)
|
||||
const uint8_t INDEX_BR[] PROGMEM = {
|
||||
constexpr uint8_t INDEX_BR[] PROGMEM = {
|
||||
0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b,
|
||||
0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48,
|
||||
0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78,
|
||||
|
||||
@@ -242,6 +242,9 @@ void CC1101Component::begin_tx() {
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
|
||||
}
|
||||
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
|
||||
// block TX entry when strobing from RX, and to ensure FS_AUTOCAL calibration
|
||||
this->enter_idle_();
|
||||
if (!this->enter_tx_()) {
|
||||
ESP_LOGW(TAG, "Failed to enter TX state!");
|
||||
}
|
||||
@@ -252,6 +255,8 @@ void CC1101Component::begin_rx() {
|
||||
if (this->gdo0_pin_ != nullptr) {
|
||||
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
|
||||
}
|
||||
// Transition through IDLE to ensure FS_AUTOCAL calibration occurs
|
||||
this->enter_idle_();
|
||||
if (!this->enter_rx_()) {
|
||||
ESP_LOGW(TAG, "Failed to enter RX state!");
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ const LogString *climate_mode_to_string(ClimateMode mode) {
|
||||
return ClimateModeStrings::get_log_str(static_cast<uint8_t>(mode), ClimateModeStrings::LAST_INDEX);
|
||||
}
|
||||
|
||||
// Climate action strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
|
||||
PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN", "UNKNOWN");
|
||||
// Climate action strings indexed by ClimateAction enum (0,2-7): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN,
|
||||
// DEFROSTING
|
||||
PROGMEM_STRING_TABLE(ClimateActionStrings, "OFF", "UNKNOWN", "COOLING", "HEATING", "IDLE", "DRYING", "FAN",
|
||||
"DEFROSTING", "UNKNOWN");
|
||||
|
||||
const LogString *climate_action_to_string(ClimateAction action) {
|
||||
return ClimateActionStrings::get_log_str(static_cast<uint8_t>(action), ClimateActionStrings::LAST_INDEX);
|
||||
|
||||
@@ -41,6 +41,8 @@ enum ClimateAction : uint8_t {
|
||||
CLIMATE_ACTION_DRYING = 5,
|
||||
/// The climate device is in fan only mode
|
||||
CLIMATE_ACTION_FAN = 6,
|
||||
/// The climate device is defrosting
|
||||
CLIMATE_ACTION_DEFROSTING = 7,
|
||||
};
|
||||
|
||||
/// NOTE: If adding values, update ClimateFanModeMask in climate_traits.h to use the new last value
|
||||
|
||||
@@ -13,22 +13,63 @@ esp_ldo_ns = cg.esphome_ns.namespace("esp_ldo")
|
||||
EspLdo = esp_ldo_ns.class_("EspLdo", cg.Component)
|
||||
AdjustAction = esp_ldo_ns.class_("AdjustAction", Action)
|
||||
|
||||
CHANNELS = (3, 4)
|
||||
CHANNELS = (1, 2, 3, 4)
|
||||
CHANNELS_INTERNAL = (1, 2)
|
||||
CONF_ADJUSTABLE = "adjustable"
|
||||
CONF_ALLOW_INTERNAL_CHANNEL = "allow_internal_channel"
|
||||
CONF_PASSTHROUGH = "passthrough"
|
||||
|
||||
adjusted_ids = set()
|
||||
|
||||
|
||||
def validate_ldo_voltage(value):
|
||||
if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH:
|
||||
return CONF_PASSTHROUGH
|
||||
value = cv.voltage(value)
|
||||
if 0.5 <= value <= 2.7:
|
||||
return value
|
||||
raise cv.Invalid(
|
||||
f"LDO voltage must be in range 0.5V-2.7V or 'passthrough' (bypass mode), got {value}V"
|
||||
)
|
||||
|
||||
|
||||
def validate_ldo_config(config):
|
||||
channel = config[CONF_CHANNEL]
|
||||
allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL]
|
||||
if allow_internal and channel not in CHANNELS_INTERNAL:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ALLOW_INTERNAL_CHANNEL}' is only valid for internal channels (1, 2). "
|
||||
f"Channel {channel} is a user-configurable channel — its usage depends on your board schematic.",
|
||||
path=[CONF_ALLOW_INTERNAL_CHANNEL],
|
||||
)
|
||||
if channel in CHANNELS_INTERNAL and not allow_internal:
|
||||
raise cv.Invalid(
|
||||
f"LDO channel {channel} is normally used internally by the chip (flash/PSRAM). "
|
||||
f"Set '{CONF_ALLOW_INTERNAL_CHANNEL}: true' to confirm you know what you are doing.",
|
||||
path=[CONF_CHANNEL],
|
||||
)
|
||||
if config[CONF_VOLTAGE] == CONF_PASSTHROUGH and config[CONF_ADJUSTABLE]:
|
||||
raise cv.Invalid(
|
||||
"Passthrough mode passes the supply voltage directly to the output and does not support "
|
||||
"runtime voltage adjustment.",
|
||||
path=[CONF_ADJUSTABLE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.ensure_list(
|
||||
cv.COMPONENT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(EspLdo),
|
||||
cv.Required(CONF_VOLTAGE): cv.All(
|
||||
cv.voltage, cv.float_range(min=0.5, max=2.7)
|
||||
),
|
||||
cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True),
|
||||
cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean,
|
||||
}
|
||||
cv.All(
|
||||
cv.COMPONENT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(EspLdo),
|
||||
cv.Required(CONF_VOLTAGE): validate_ldo_voltage,
|
||||
cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True),
|
||||
cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ALLOW_INTERNAL_CHANNEL, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
validate_ldo_config,
|
||||
)
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
@@ -40,7 +81,11 @@ async def to_code(configs):
|
||||
for config in configs:
|
||||
var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_voltage(config[CONF_VOLTAGE]))
|
||||
voltage = config[CONF_VOLTAGE]
|
||||
if voltage == CONF_PASSTHROUGH:
|
||||
cg.add(var.set_voltage(3300))
|
||||
else:
|
||||
cg.add(var.set_voltage(int(round(voltage * 1000))))
|
||||
cg.add(var.set_adjustable(config[CONF_ADJUSTABLE]))
|
||||
|
||||
|
||||
|
||||
@@ -10,32 +10,34 @@ static const char *const TAG = "esp_ldo";
|
||||
void EspLdo::setup() {
|
||||
esp_ldo_channel_config_t config{};
|
||||
config.chan_id = this->channel_;
|
||||
config.voltage_mv = (int) (this->voltage_ * 1000.0f);
|
||||
config.voltage_mv = this->voltage_mv_;
|
||||
config.flags.adjustable = this->adjustable_;
|
||||
auto err = esp_ldo_acquire_channel(&config, &this->handle_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_);
|
||||
ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %dmV", this->channel_, this->voltage_mv_);
|
||||
this->mark_failed(LOG_STR("Failed to acquire LDO channel"));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_);
|
||||
ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %dmV", this->channel_, this->voltage_mv_);
|
||||
}
|
||||
}
|
||||
void EspLdo::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"ESP LDO Channel %d:\n"
|
||||
" Voltage: %fV\n"
|
||||
" Voltage: %dmV\n"
|
||||
" Adjustable: %s",
|
||||
this->channel_, this->voltage_, YESNO(this->adjustable_));
|
||||
this->channel_, this->voltage_mv_, YESNO(this->adjustable_));
|
||||
}
|
||||
|
||||
void EspLdo::adjust_voltage(float voltage) {
|
||||
if (!std::isfinite(voltage) || voltage < 0.5f || voltage > 2.7f) {
|
||||
ESP_LOGE(TAG, "Invalid voltage %fV for LDO channel %d", voltage, this->channel_);
|
||||
ESP_LOGE(TAG, "Invalid voltage %fV for LDO channel %d (must be 0.5V-2.7V)", voltage, this->channel_);
|
||||
return;
|
||||
}
|
||||
auto erro = esp_ldo_channel_adjust_voltage(this->handle_, (int) (voltage * 1000.0f));
|
||||
if (erro != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to adjust LDO channel %d to voltage %fV: %s", this->channel_, voltage, esp_err_to_name(erro));
|
||||
int voltage_mv = (int) roundf(voltage * 1000.0f);
|
||||
auto err = esp_ldo_channel_adjust_voltage(this->handle_, voltage_mv);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to adjust LDO channel %d to voltage %dmV: %s", this->channel_, voltage_mv,
|
||||
esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class EspLdo : public Component {
|
||||
void dump_config() override;
|
||||
|
||||
void set_adjustable(bool adjustable) { this->adjustable_ = adjustable; }
|
||||
void set_voltage(float voltage) { this->voltage_ = voltage; }
|
||||
void set_voltage(int voltage_mv) { this->voltage_mv_ = voltage_mv; }
|
||||
void adjust_voltage(float voltage);
|
||||
float get_setup_priority() const override {
|
||||
return setup_priority::BUS; // LDO setup should be done early
|
||||
@@ -23,7 +23,7 @@ class EspLdo : public Component {
|
||||
|
||||
protected:
|
||||
int channel_;
|
||||
float voltage_{2.7};
|
||||
int voltage_mv_{2700};
|
||||
bool adjustable_{false};
|
||||
esp_ldo_channel_handle_t handle_{};
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
namespace esphome {
|
||||
|
||||
/// ESPHomeOTAComponent provides a simple way to integrate Over-the-Air updates into your app using ArduinoOTA.
|
||||
class ESPHomeOTAComponent : public ota::OTAComponent {
|
||||
class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
public:
|
||||
enum class OTAState : uint8_t {
|
||||
IDLE,
|
||||
|
||||
@@ -22,7 +22,7 @@ enum OtaHttpRequestError : uint8_t {
|
||||
OTA_CONNECTION_ERROR = 0x12,
|
||||
};
|
||||
|
||||
class OtaHttpRequestComponent : public ota::OTAComponent, public Parented<HttpRequestComponent> {
|
||||
class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<HttpRequestComponent> {
|
||||
public:
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
@@ -27,6 +27,7 @@ from esphome.storage_json import StorageJSON
|
||||
|
||||
from . import gpio # noqa
|
||||
from .const import (
|
||||
COMPONENT_BK72XX,
|
||||
CONF_GPIO_RECOVER,
|
||||
CONF_LOGLEVEL,
|
||||
CONF_SDK_SILENT,
|
||||
@@ -453,7 +454,14 @@ async def component_to_code(config):
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "soft")
|
||||
# include <Arduino.h> in every file
|
||||
cg.add_platformio_option("build_src_flags", "-include Arduino.h")
|
||||
build_src_flags = "-include Arduino.h"
|
||||
if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_BK72XX:
|
||||
# LibreTiny forces -O1 globally for BK72xx because the Beken SDK
|
||||
# has issues with higher optimization levels. However, ESPHome code
|
||||
# works fine with -Os (used on every other platform), so override
|
||||
# it for project source files only. GCC uses the last -O flag.
|
||||
build_src_flags += " -Os"
|
||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||
# dummy version code
|
||||
cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)"))
|
||||
# decrease web server stack size (16k words -> 4k words)
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {} // namespace libretiny
|
||||
} // namespace esphome
|
||||
namespace esphome::libretiny {} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
#include "gpio_arduino.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {
|
||||
namespace esphome::libretiny {
|
||||
|
||||
static const char *const TAG = "lt.gpio";
|
||||
|
||||
@@ -77,7 +76,9 @@ void ArduinoInternalGPIOPin::detach_interrupt() const {
|
||||
detachInterrupt(pin_); // NOLINT
|
||||
}
|
||||
|
||||
} // namespace libretiny
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
namespace esphome {
|
||||
|
||||
using namespace libretiny;
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
#ifdef USE_LIBRETINY
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {
|
||||
namespace esphome::libretiny {
|
||||
|
||||
class ArduinoInternalGPIOPin : public InternalGPIOPin {
|
||||
public:
|
||||
@@ -31,7 +30,6 @@ class ArduinoInternalGPIOPin : public InternalGPIOPin {
|
||||
gpio::Flags flags_{};
|
||||
};
|
||||
|
||||
} // namespace libretiny
|
||||
} // namespace esphome
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {
|
||||
namespace esphome::libretiny {
|
||||
|
||||
static const char *const TAG = "lt.component";
|
||||
|
||||
@@ -15,6 +14,9 @@ void LTComponent::dump_config() {
|
||||
" Version: %s\n"
|
||||
" Loglevel: %u",
|
||||
LT_BANNER_STR + 10, LT_LOGLEVEL);
|
||||
#if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3
|
||||
ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__));
|
||||
#endif
|
||||
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
if (this->version_ != nullptr) {
|
||||
@@ -25,7 +27,6 @@ void LTComponent::dump_config() {
|
||||
|
||||
float LTComponent::get_setup_priority() const { return setup_priority::LATE; }
|
||||
|
||||
} // namespace libretiny
|
||||
} // namespace esphome
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {
|
||||
namespace esphome::libretiny {
|
||||
|
||||
class LTComponent : public Component {
|
||||
public:
|
||||
@@ -30,7 +29,6 @@ class LTComponent : public Component {
|
||||
#endif // USE_TEXT_SENSOR
|
||||
};
|
||||
|
||||
} // namespace libretiny
|
||||
} // namespace esphome
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {
|
||||
namespace esphome::libretiny {
|
||||
|
||||
static const char *const TAG = "lt.preferences";
|
||||
|
||||
@@ -194,7 +193,9 @@ void setup_preferences() {
|
||||
global_preferences = &s_preferences;
|
||||
}
|
||||
|
||||
} // namespace libretiny
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
namespace esphome {
|
||||
|
||||
ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
namespace esphome {
|
||||
namespace libretiny {
|
||||
namespace esphome::libretiny {
|
||||
|
||||
void setup_preferences();
|
||||
|
||||
} // namespace libretiny
|
||||
} // namespace esphome
|
||||
} // namespace esphome::libretiny
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -141,7 +141,7 @@ enum UARTSelection : uint8_t {
|
||||
* 2. Works with ESP-IDF's pthread implementation that uses a linked list for TLS variables
|
||||
* 3. Avoids the limitations of the fixed FreeRTOS task local storage slots
|
||||
*/
|
||||
class Logger : public Component {
|
||||
class Logger final : public Component {
|
||||
public:
|
||||
explicit Logger(uint32_t baud_rate);
|
||||
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
|
||||
@@ -481,7 +481,7 @@ class Logger : public Component {
|
||||
};
|
||||
extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
class LoggerMessageTrigger : public Trigger<uint8_t, const char *, const char *> {
|
||||
class LoggerMessageTrigger final : public Trigger<uint8_t, const char *, const char *> {
|
||||
public:
|
||||
explicit LoggerMessageTrigger(Logger *parent, uint8_t level) : level_(level) {
|
||||
parent->add_log_callback(this,
|
||||
|
||||
@@ -40,7 +40,7 @@ struct MDNSService {
|
||||
FixedVector<MDNSTXTRecord> txt_records;
|
||||
};
|
||||
|
||||
class MDNSComponent : public Component {
|
||||
class MDNSComponent final : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
@@ -2,7 +2,11 @@ from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
|
||||
# fmt: off
|
||||
DriverChip(
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage
|
||||
JD9365_10_1_DSI_TOUCH_A = DriverChip(
|
||||
"WAVESHARE-P4-NANO-10.1",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -52,6 +56,15 @@ DriverChip(
|
||||
],
|
||||
)
|
||||
|
||||
# Standalone display
|
||||
# Product page: https://www.waveshare.com/wiki/10.1-DSI-TOUCH-A
|
||||
JD9365_10_1_DSI_TOUCH_A.extend(
|
||||
"WAVESHARE-10.1-DSI-TOUCH-A",
|
||||
)
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO
|
||||
DriverChip(
|
||||
"WAVESHARE-P4-86-PANEL",
|
||||
height=720,
|
||||
@@ -95,6 +108,9 @@ DriverChip(
|
||||
],
|
||||
)
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B
|
||||
DriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B",
|
||||
height=600,
|
||||
@@ -121,7 +137,10 @@ DriverChip(
|
||||
],
|
||||
)
|
||||
|
||||
DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C
|
||||
JD9365_3_4_DSI_TOUCH_C = DriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
|
||||
height=800,
|
||||
width=800,
|
||||
@@ -170,7 +189,16 @@ DriverChip(
|
||||
],
|
||||
)
|
||||
|
||||
DriverChip(
|
||||
# Standalone display
|
||||
# Product page: https://www.waveshare.com/wiki/3.4-DSI-TOUCH-C
|
||||
JD9365_3_4_DSI_TOUCH_C.extend(
|
||||
"WAVESHARE-3.4-DSI-TOUCH-C",
|
||||
)
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C
|
||||
JD9365_4_DSI_TOUCH_C = DriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
|
||||
height=720,
|
||||
width=720,
|
||||
@@ -218,3 +246,108 @@ DriverChip(
|
||||
(0xE0, 0x00), # select userpage
|
||||
]
|
||||
)
|
||||
|
||||
# Standalone display
|
||||
# Product page: https://www.waveshare.com/wiki/4-DSI-TOUCH-C
|
||||
JD9365_4_DSI_TOUCH_C.extend(
|
||||
"WAVESHARE-4-DSI-TOUCH-C",
|
||||
)
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
|
||||
# Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A
|
||||
DriverChip(
|
||||
"WAVESHARE-8-DSI-TOUCH-A",
|
||||
height=1280,
|
||||
width=800,
|
||||
hsync_back_porch=20,
|
||||
hsync_pulse_width=20,
|
||||
hsync_front_porch=40,
|
||||
vsync_back_porch=12,
|
||||
vsync_pulse_width=4,
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
(0xE1, 0x93), (0xE2, 0x65), (0xE3, 0xF8),
|
||||
(0x80, 0x01), # Select number of lanes (2)
|
||||
(0xE0, 0x01), # select page 1
|
||||
(0x00, 0x00), (0x01, 0x4E), (0x03, 0x00), (0x04, 0x65), (0x0C, 0x74), (0x17, 0x00), (0x18, 0xB7), (0x19, 0x00),
|
||||
(0x1A, 0x00), (0x1B, 0xB7), (0x1C, 0x00), (0x24, 0xFE), (0x37, 0x19), (0x38, 0x05), (0x39, 0x00), (0x3A, 0x01),
|
||||
(0x3B, 0x01), (0x3C, 0x70), (0x3D, 0xFF), (0x3E, 0xFF), (0x3F, 0xFF), (0x40, 0x06), (0x41, 0xA0), (0x43, 0x1E),
|
||||
(0x44, 0x0F), (0x45, 0x28), (0x4B, 0x04), (0x55, 0x02), (0x56, 0x01), (0x57, 0xA9), (0x58, 0x0A), (0x59, 0x0A),
|
||||
(0x5A, 0x37), (0x5B, 0x19), (0x5D, 0x78), (0x5E, 0x63), (0x5F, 0x54), (0x60, 0x49), (0x61, 0x45), (0x62, 0x38),
|
||||
(0x63, 0x3D), (0x64, 0x28), (0x65, 0x43), (0x66, 0x41), (0x67, 0x43), (0x68, 0x62), (0x69, 0x50), (0x6A, 0x57),
|
||||
(0x6B, 0x49), (0x6C, 0x44), (0x6D, 0x37), (0x6E, 0x23), (0x6F, 0x10), (0x70, 0x78), (0x71, 0x63), (0x72, 0x54),
|
||||
(0x73, 0x49), (0x74, 0x45), (0x75, 0x38), (0x76, 0x3D), (0x77, 0x28), (0x78, 0x43), (0x79, 0x41), (0x7A, 0x43),
|
||||
(0x7B, 0x62), (0x7C, 0x50), (0x7D, 0x57), (0x7E, 0x49), (0x7F, 0x44), (0x80, 0x37), (0x81, 0x23), (0x82, 0x10),
|
||||
(0xE0, 0x02), # select page 2
|
||||
(0x00, 0x47), (0x01, 0x47), (0x02, 0x45), (0x03, 0x45), (0x04, 0x4B), (0x05, 0x4B), (0x06, 0x49), (0x07, 0x49),
|
||||
(0x08, 0x41), (0x09, 0x1F), (0x0A, 0x1F), (0x0B, 0x1F), (0x0C, 0x1F), (0x0D, 0x1F), (0x0E, 0x1F), (0x0F, 0x5F),
|
||||
(0x10, 0x5F), (0x11, 0x57), (0x12, 0x77), (0x13, 0x35), (0x14, 0x1F), (0x15, 0x1F), (0x16, 0x46), (0x17, 0x46),
|
||||
(0x18, 0x44), (0x19, 0x44), (0x1A, 0x4A), (0x1B, 0x4A), (0x1C, 0x48), (0x1D, 0x48), (0x1E, 0x40), (0x1F, 0x1F),
|
||||
(0x20, 0x1F), (0x21, 0x1F), (0x22, 0x1F), (0x23, 0x1F), (0x24, 0x1F), (0x25, 0x5F), (0x26, 0x5F), (0x27, 0x57),
|
||||
(0x28, 0x77), (0x29, 0x35), (0x2A, 0x1F), (0x2B, 0x1F), (0x58, 0x40), (0x59, 0x00), (0x5A, 0x00), (0x5B, 0x10),
|
||||
(0x5C, 0x06), (0x5D, 0x40), (0x5E, 0x01), (0x5F, 0x02), (0x60, 0x30), (0x61, 0x01), (0x62, 0x02), (0x63, 0x03),
|
||||
(0x64, 0x6B), (0x65, 0x05), (0x66, 0x0C), (0x67, 0x73), (0x68, 0x09), (0x69, 0x03), (0x6A, 0x56), (0x6B, 0x08),
|
||||
(0x6C, 0x00), (0x6D, 0x04), (0x6E, 0x04), (0x6F, 0x88), (0x70, 0x00), (0x71, 0x00), (0x72, 0x06), (0x73, 0x7B),
|
||||
(0x74, 0x00), (0x75, 0xF8), (0x76, 0x00), (0x77, 0xD5), (0x78, 0x2E), (0x79, 0x12), (0x7A, 0x03), (0x7B, 0x00),
|
||||
(0x7C, 0x00), (0x7D, 0x03), (0x7E, 0x7B),
|
||||
(0xE0, 0x04), # select page 4
|
||||
(0x00, 0x0E), (0x02, 0xB3), (0x09, 0x60), (0x0E, 0x2A), (0x36, 0x59), (0x37, 0x58), (0x2B, 0x0F),
|
||||
(0xE0, 0x00), # select userpage
|
||||
]
|
||||
)
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c
|
||||
# Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A
|
||||
DriverChip(
|
||||
"WAVESHARE-7-DSI-TOUCH-A",
|
||||
height=1280,
|
||||
width=720,
|
||||
hsync_back_porch=239,
|
||||
hsync_pulse_width=50,
|
||||
hsync_front_porch=33,
|
||||
vsync_back_porch=20,
|
||||
vsync_pulse_width=30,
|
||||
vsync_front_porch=2,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1000Mbps",
|
||||
no_transform=True,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xFF, 0x98, 0x81, 0x03),
|
||||
(0x01, 0x00), (0x02, 0x00), (0x03, 0x73), (0x04, 0x00), (0x05, 0x00), (0x06, 0x0A), (0x07, 0x00), (0x08, 0x00),
|
||||
(0x09, 0x61), (0x0A, 0x00), (0x0B, 0x00), (0x0C, 0x01), (0x0D, 0x00), (0x0E, 0x00), (0x0F, 0x61), (0x10, 0x61),
|
||||
(0x11, 0x00), (0x12, 0x00), (0x13, 0x00), (0x14, 0x00), (0x15, 0x00), (0x16, 0x00), (0x17, 0x00), (0x18, 0x00),
|
||||
(0x19, 0x00), (0x1A, 0x00), (0x1B, 0x00), (0x1C, 0x00), (0x1D, 0x00), (0x1E, 0x40), (0x1F, 0x80), (0x20, 0x06),
|
||||
(0x21, 0x01), (0x22, 0x00), (0x23, 0x00), (0x24, 0x00), (0x25, 0x00), (0x26, 0x00), (0x27, 0x00), (0x28, 0x33),
|
||||
(0x29, 0x03), (0x2A, 0x00), (0x2B, 0x00), (0x2C, 0x00), (0x2D, 0x00), (0x2E, 0x00), (0x2F, 0x00), (0x30, 0x00),
|
||||
(0x31, 0x00), (0x32, 0x00), (0x33, 0x00), (0x34, 0x04), (0x35, 0x00), (0x36, 0x00), (0x37, 0x00), (0x38, 0x3C),
|
||||
(0x39, 0x00), (0x3A, 0x00), (0x3B, 0x00), (0x3C, 0x00), (0x3D, 0x00), (0x3E, 0x00), (0x3F, 0x00), (0x40, 0x00),
|
||||
(0x41, 0x00), (0x42, 0x00), (0x43, 0x00), (0x44, 0x00), (0x50, 0x10), (0x51, 0x32), (0x52, 0x54), (0x53, 0x76),
|
||||
(0x54, 0x98), (0x55, 0xBA), (0x56, 0x10), (0x57, 0x32), (0x58, 0x54), (0x59, 0x76), (0x5A, 0x98), (0x5B, 0xBA),
|
||||
(0x5C, 0xDC), (0x5D, 0xFE), (0x5E, 0x00), (0x5F, 0x0E), (0x60, 0x0F), (0x61, 0x0C), (0x62, 0x0D), (0x63, 0x06),
|
||||
(0x64, 0x07), (0x65, 0x02), (0x66, 0x02), (0x67, 0x02), (0x68, 0x02), (0x69, 0x01), (0x6A, 0x00), (0x6B, 0x02),
|
||||
(0x6C, 0x15), (0x6D, 0x14), (0x6E, 0x02), (0x6F, 0x02), (0x70, 0x02), (0x71, 0x02), (0x72, 0x02), (0x73, 0x02),
|
||||
(0x74, 0x02), (0x75, 0x0E), (0x76, 0x0F), (0x77, 0x0C), (0x78, 0x0D), (0x79, 0x06), (0x7A, 0x07), (0x7B, 0x02),
|
||||
(0x7C, 0x02), (0x7D, 0x02), (0x7E, 0x02), (0x7F, 0x01), (0x80, 0x00), (0x81, 0x02), (0x82, 0x14), (0x83, 0x15),
|
||||
(0x84, 0x02), (0x85, 0x02), (0x86, 0x02), (0x87, 0x02), (0x88, 0x02), (0x89, 0x02), (0x8A, 0x02),
|
||||
(0xFF, 0x98, 0x81, 0x04),
|
||||
(0x38, 0x01), (0x39, 0x00), (0x6C, 0x15), (0x6E, 0x2A), (0x6F, 0x33), (0x3A, 0x94), (0x8D, 0x14), (0x87, 0xBA),
|
||||
(0x26, 0x76), (0xB2, 0xD1), (0xB5, 0x06), (0x3B, 0x98),
|
||||
(0xFF, 0x98, 0x81, 0x01),
|
||||
(0x22, 0x0A), (0x31, 0x00), (0x53, 0x71), (0x55, 0x8F), (0x40, 0x33), (0x50, 0x96), (0x51, 0x96), (0x60, 0x23),
|
||||
(0xA0, 0x08), (0xA1, 0x1D), (0xA2, 0x2A), (0xA3, 0x10), (0xA4, 0x15), (0xA5, 0x28), (0xA6, 0x1C), (0xA7, 0x1D),
|
||||
(0xA8, 0x7E), (0xA9, 0x1D), (0xAA, 0x29), (0xAB, 0x6B), (0xAC, 0x1A), (0xAD, 0x18), (0xAE, 0x4B), (0xAF, 0x20),
|
||||
(0xB0, 0x27), (0xB1, 0x50), (0xB2, 0x64), (0xB3, 0x39), (0xC0, 0x08), (0xC1, 0x1D), (0xC2, 0x2A), (0xC3, 0x10),
|
||||
(0xC4, 0x15), (0xC5, 0x28), (0xC6, 0x1C), (0xC7, 0x1D), (0xC8, 0x7E), (0xC9, 0x1D), (0xCA, 0x29), (0xCB, 0x6B),
|
||||
(0xCC, 0x1A), (0xCD, 0x18), (0xCE, 0x4B), (0xCF, 0x20), (0xD0, 0x27), (0xD1, 0x50), (0xD2, 0x64), (0xD3, 0x39),
|
||||
(0xFF, 0x98, 0x81, 0x00),
|
||||
(0x3A, 0x77), (0x36, 0x00), (0x35, 0x00), (0x35, 0x00),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -20,9 +20,10 @@ static ProgmemStr climate_mode_to_mqtt_str(ClimateMode mode) {
|
||||
return ClimateMqttModeStrings::get_progmem_str(static_cast<uint8_t>(mode), ClimateMqttModeStrings::LAST_INDEX);
|
||||
}
|
||||
|
||||
// Climate action MQTT strings indexed by ClimateAction enum (0,2-6): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN
|
||||
// Climate action MQTT strings indexed by ClimateAction enum (0,2-7): OFF, (gap), COOLING, HEATING, IDLE, DRYING, FAN,
|
||||
// DEFROSTING
|
||||
PROGMEM_STRING_TABLE(ClimateMqttActionStrings, "off", "unknown", "cooling", "heating", "idle", "drying", "fan",
|
||||
"unknown");
|
||||
"defrosting", "unknown");
|
||||
|
||||
static ProgmemStr climate_action_to_mqtt_str(ClimateAction action) {
|
||||
return ClimateMqttActionStrings::get_progmem_str(static_cast<uint8_t>(action), ClimateMqttActionStrings::LAST_INDEX);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
namespace esphome {
|
||||
namespace ota {
|
||||
|
||||
class ArduinoLibreTinyOTABackend : public OTABackend {
|
||||
class ArduinoLibreTinyOTABackend final : public OTABackend {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size) override;
|
||||
void set_update_md5(const char *md5) override;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
namespace esphome {
|
||||
namespace ota {
|
||||
|
||||
class ArduinoRP2040OTABackend : public OTABackend {
|
||||
class ArduinoRP2040OTABackend final : public OTABackend {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size) override;
|
||||
void set_update_md5(const char *md5) override;
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace esphome::ota {
|
||||
/// OTA backend for ESP8266 using native SDK functions.
|
||||
/// This implementation bypasses the Arduino Updater library to save ~228 bytes of RAM
|
||||
/// by not having a global Update object in .bss.
|
||||
class ESP8266OTABackend : public OTABackend {
|
||||
class ESP8266OTABackend final : public OTABackend {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size) override;
|
||||
void set_update_md5(const char *md5) override;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace esphome {
|
||||
namespace ota {
|
||||
|
||||
class IDFOTABackend : public OTABackend {
|
||||
class IDFOTABackend final : public OTABackend {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size) override;
|
||||
void set_update_md5(const char *md5) override;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace esphome::ota {
|
||||
/// Stub OTA backend for host platform - allows compilation but does not implement OTA.
|
||||
/// All operations return error codes immediately. This enables configurations with
|
||||
/// OTA triggers to compile for host platform during development.
|
||||
class HostOTABackend : public OTABackend {
|
||||
class HostOTABackend final : public OTABackend {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size) override;
|
||||
void set_update_md5(const char *md5) override;
|
||||
|
||||
@@ -91,18 +91,18 @@ def _parse_platform_version(value):
|
||||
# The default/recommended arduino framework version
|
||||
# - https://github.com/earlephilhower/arduino-pico/releases
|
||||
# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico
|
||||
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 9, 4)
|
||||
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 0)
|
||||
|
||||
# The raspberrypi platform version to use for arduino frameworks
|
||||
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
|
||||
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.2.0-gcc12"
|
||||
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460"
|
||||
|
||||
|
||||
def _arduino_check_versions(value):
|
||||
value = value.copy()
|
||||
lookups = {
|
||||
"dev": (cv.Version(3, 9, 4), "https://github.com/earlephilhower/arduino-pico"),
|
||||
"latest": (cv.Version(3, 9, 4), None),
|
||||
"dev": (cv.Version(5, 5, 0), "https://github.com/earlephilhower/arduino-pico"),
|
||||
"latest": (cv.Version(5, 5, 0), None),
|
||||
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
|
||||
sio_hw->gpio_oe_set = arg->mask;
|
||||
} else if (flags & gpio::FLAG_INPUT) {
|
||||
sio_hw->gpio_oe_clr = arg->mask;
|
||||
hw_write_masked(&padsbank0_hw->io[arg->pin],
|
||||
hw_write_masked(&pads_bank0_hw->io[arg->pin],
|
||||
(bool_to_bit(flags & gpio::FLAG_PULLUP) << PADS_BANK0_GPIO0_PUE_LSB) |
|
||||
(bool_to_bit(flags & gpio::FLAG_PULLDOWN) << PADS_BANK0_GPIO0_PDE_LSB),
|
||||
PADS_BANK0_GPIO0_PUE_BITS | PADS_BANK0_GPIO0_PDE_BITS);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace esphome::safe_mode {
|
||||
|
||||
class SafeModeTrigger : public Trigger<> {
|
||||
class SafeModeTrigger final : public Trigger<> {
|
||||
public:
|
||||
explicit SafeModeTrigger(SafeModeComponent *parent) {
|
||||
parent->add_on_safe_mode_callback([this]() { trigger(); });
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace esphome::safe_mode {
|
||||
constexpr uint32_t RTC_KEY = 233825507UL;
|
||||
|
||||
/// SafeModeComponent provides a safe way to recover from repeated boot failures
|
||||
class SafeModeComponent : public Component {
|
||||
class SafeModeComponent final : public Component {
|
||||
public:
|
||||
bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after);
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ DELTA_SCHEMA = cv.Any(
|
||||
def _get_delta(value):
|
||||
if isinstance(value, str):
|
||||
assert value.endswith("%")
|
||||
return 0.0, float(value[:-1])
|
||||
return 0.0, float(value[:-1]) / 100.0
|
||||
return value, 0.0
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,8 @@ def require_wake_loop_threadsafe() -> None:
|
||||
IMPORTANT: This is for background thread context only, NOT ISR context.
|
||||
Socket operations are not safe to call from ISR handlers.
|
||||
|
||||
On ESP32, FreeRTOS task notifications are used instead (no socket needed).
|
||||
|
||||
Example:
|
||||
from esphome.components import socket
|
||||
|
||||
@@ -147,8 +149,10 @@ def require_wake_loop_threadsafe() -> None:
|
||||
):
|
||||
CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True
|
||||
cg.add_define("USE_WAKE_LOOP_THREADSAFE")
|
||||
# Consume 1 socket for the shared wake notification socket
|
||||
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
|
||||
if not CORE.is_esp32:
|
||||
# Only non-ESP32 platforms need a UDP socket for wake notifications.
|
||||
# ESP32 uses FreeRTOS task notifications instead (no socket needed).
|
||||
consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
|
||||
@@ -71,7 +71,7 @@ class Socket {
|
||||
int get_fd() const { return -1; }
|
||||
#endif
|
||||
|
||||
/// Check if socket has data ready to read
|
||||
/// Check if socket has data ready to read. Must only be called from the main loop thread.
|
||||
/// For select()-based sockets: non-virtual, checks Application's select() results
|
||||
/// For LWIP raw TCP sockets: virtual, checks internal buffer state
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
|
||||
@@ -84,32 +84,30 @@ SprinklerValveOperator::SprinklerValveOperator(SprinklerValve *valve, Sprinkler
|
||||
: controller_(controller), valve_(valve) {}
|
||||
|
||||
void SprinklerValveOperator::loop() {
|
||||
// Use wrapping subtraction so 32-bit millis() rollover is handled correctly:
|
||||
// (now - start) yields the true elapsed time even across the 49.7-day boundary.
|
||||
uint32_t now = App.get_loop_component_start_time();
|
||||
if (now >= this->start_millis_) { // dummy check
|
||||
switch (this->state_) {
|
||||
case STARTING:
|
||||
if (now > (this->start_millis_ + this->start_delay_)) {
|
||||
this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state
|
||||
}
|
||||
break;
|
||||
switch (this->state_) {
|
||||
case STARTING:
|
||||
if ((now - *this->start_millis_) > this->start_delay_) {
|
||||
this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state
|
||||
}
|
||||
break;
|
||||
|
||||
case ACTIVE:
|
||||
if (now > (this->start_millis_ + this->start_delay_ + this->run_duration_)) {
|
||||
this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down
|
||||
}
|
||||
break;
|
||||
case ACTIVE:
|
||||
if ((now - *this->start_millis_) > (this->start_delay_ + this->run_duration_)) {
|
||||
this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down
|
||||
}
|
||||
break;
|
||||
|
||||
case STOPPING:
|
||||
if (now > (this->stop_millis_ + this->stop_delay_)) {
|
||||
this->kill_(); // stop_delay_has been exceeded, ensure all valves are off
|
||||
}
|
||||
break;
|
||||
case STOPPING:
|
||||
if ((now - *this->stop_millis_) > this->stop_delay_) {
|
||||
this->kill_(); // stop_delay_has been exceeded, ensure all valves are off
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else { // perhaps millis() rolled over...or something else is horribly wrong!
|
||||
this->stop(); // bail out (TODO: handle this highly unlikely situation better...)
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +122,11 @@ void SprinklerValveOperator::set_valve(SprinklerValve *valve) {
|
||||
if (this->state_ != IDLE) { // Only kill if not already idle
|
||||
this->kill_(); // ensure everything is off before we let go!
|
||||
}
|
||||
this->state_ = IDLE; // reset state
|
||||
this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it
|
||||
this->start_millis_ = 0; // reset because (new) valve has not been started yet
|
||||
this->stop_millis_ = 0; // reset because (new) valve has not been started yet
|
||||
this->valve_ = valve; // finally, set the pointer to the new valve
|
||||
this->state_ = IDLE; // reset state
|
||||
this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it
|
||||
this->start_millis_.reset(); // reset because (new) valve has not been started yet
|
||||
this->stop_millis_.reset(); // reset because (new) valve has not been started yet
|
||||
this->valve_ = valve; // finally, set the pointer to the new valve
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +160,7 @@ void SprinklerValveOperator::start() {
|
||||
} else {
|
||||
this->run_(); // there is no start_delay_, so just start the pump and valve
|
||||
}
|
||||
this->stop_millis_ = 0;
|
||||
this->stop_millis_.reset();
|
||||
this->start_millis_ = millis(); // save the time the start request was made
|
||||
}
|
||||
|
||||
@@ -189,22 +187,25 @@ void SprinklerValveOperator::stop() {
|
||||
uint32_t SprinklerValveOperator::run_duration() { return this->run_duration_ / 1000; }
|
||||
|
||||
uint32_t SprinklerValveOperator::time_remaining() {
|
||||
if (this->start_millis_ == 0) {
|
||||
if (!this->start_millis_.has_value()) {
|
||||
return this->run_duration(); // hasn't been started yet
|
||||
}
|
||||
|
||||
if (this->stop_millis_) {
|
||||
if (this->stop_millis_ - this->start_millis_ >= this->start_delay_ + this->run_duration_) {
|
||||
if (this->stop_millis_.has_value()) {
|
||||
uint32_t elapsed = *this->stop_millis_ - *this->start_millis_;
|
||||
if (elapsed >= this->start_delay_ + this->run_duration_) {
|
||||
return 0; // valve was active for more than its configured duration, so we are done
|
||||
} else {
|
||||
// we're stopped; return time remaining
|
||||
return (this->run_duration_ - (this->stop_millis_ - this->start_millis_)) / 1000;
|
||||
}
|
||||
if (elapsed <= this->start_delay_) {
|
||||
return this->run_duration_ / 1000; // stopped during start delay, full run duration remains
|
||||
}
|
||||
return (this->run_duration_ - (elapsed - this->start_delay_)) / 1000;
|
||||
}
|
||||
|
||||
auto completed_millis = this->start_millis_ + this->start_delay_ + this->run_duration_;
|
||||
if (completed_millis > millis()) {
|
||||
return (completed_millis - millis()) / 1000; // running now
|
||||
uint32_t elapsed = millis() - *this->start_millis_;
|
||||
uint32_t total_duration = this->start_delay_ + this->run_duration_;
|
||||
if (elapsed < total_duration) {
|
||||
return (total_duration - elapsed) / 1000; // running now
|
||||
}
|
||||
return 0; // run completed
|
||||
}
|
||||
@@ -593,7 +594,7 @@ void Sprinkler::set_repeat(optional<uint32_t> repeat) {
|
||||
if (this->repeat_number_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (this->repeat_number_->state == repeat.value()) {
|
||||
if (this->repeat_number_->state == repeat.value_or(0)) {
|
||||
return;
|
||||
}
|
||||
auto call = this->repeat_number_->make_call();
|
||||
@@ -793,7 +794,7 @@ void Sprinkler::start_single_valve(const optional<size_t> valve_number, optional
|
||||
void Sprinkler::queue_valve(optional<size_t> valve_number, optional<uint32_t> run_duration) {
|
||||
if (valve_number.has_value()) {
|
||||
if (this->is_a_valid_valve(valve_number.value()) && (this->queued_valves_.size() < this->max_queue_size_)) {
|
||||
SprinklerQueueItem item{valve_number.value(), run_duration.value()};
|
||||
SprinklerQueueItem item{valve_number.value(), run_duration.value_or(0)};
|
||||
this->queued_valves_.insert(this->queued_valves_.begin(), item);
|
||||
ESP_LOGD(TAG, "Valve %zu placed into queue with run duration of %" PRIu32 " seconds", valve_number.value_or(0),
|
||||
run_duration.value_or(0));
|
||||
@@ -1080,7 +1081,7 @@ uint32_t Sprinkler::total_cycle_time_enabled_incomplete_valves() {
|
||||
}
|
||||
}
|
||||
|
||||
if (incomplete_valve_count >= enabled_valve_count) {
|
||||
if (incomplete_valve_count > 0 && incomplete_valve_count >= enabled_valve_count) {
|
||||
incomplete_valve_count--;
|
||||
}
|
||||
if (incomplete_valve_count) {
|
||||
|
||||
@@ -141,8 +141,8 @@ class SprinklerValveOperator {
|
||||
uint32_t start_delay_{0};
|
||||
uint32_t stop_delay_{0};
|
||||
uint32_t run_duration_{0};
|
||||
uint64_t start_millis_{0};
|
||||
uint64_t stop_millis_{0};
|
||||
optional<uint32_t> start_millis_{};
|
||||
optional<uint32_t> stop_millis_{};
|
||||
Sprinkler *controller_{nullptr};
|
||||
SprinklerValve *valve_{nullptr};
|
||||
SprinklerState state_{IDLE};
|
||||
|
||||
488
esphome/components/time/posix_tz.cpp
Normal file
488
esphome/components/time/posix_tz.cpp
Normal file
@@ -0,0 +1,488 @@
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
|
||||
#include "posix_tz.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
// Global timezone - set once at startup, rarely changes
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state
|
||||
static ParsedTimezone global_tz_{};
|
||||
|
||||
void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; }
|
||||
|
||||
const ParsedTimezone &get_global_tz() { return global_tz_; }
|
||||
|
||||
namespace internal {
|
||||
|
||||
// Remove before 2026.9.0: parse_uint, skip_tz_name, parse_offset, parse_dst_rule,
|
||||
// and parse_transition_time are only used by parse_posix_tz() (bridge code).
|
||||
static uint32_t parse_uint(const char *&p) {
|
||||
uint32_t value = 0;
|
||||
while (std::isdigit(static_cast<unsigned char>(*p))) {
|
||||
value = value * 10 + (*p - '0');
|
||||
p++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }
|
||||
|
||||
// Get days in year (avoids duplicate is_leap_year calls)
|
||||
static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; }
|
||||
|
||||
// Convert days since epoch to year, updating days to remainder
|
||||
static int __attribute__((noinline)) days_to_year(int64_t &days) {
|
||||
int year = 1970;
|
||||
int diy;
|
||||
while (days >= (diy = days_in_year(year)) && year < 2200) {
|
||||
days -= diy;
|
||||
year++;
|
||||
}
|
||||
while (days < 0 && year > 1900) {
|
||||
year--;
|
||||
days += days_in_year(year);
|
||||
}
|
||||
return year;
|
||||
}
|
||||
|
||||
// Extract just the year from a UTC epoch
|
||||
static int epoch_to_year(time_t epoch) {
|
||||
int64_t days = epoch / 86400;
|
||||
if (epoch < 0 && epoch % 86400 != 0)
|
||||
days--;
|
||||
return days_to_year(days);
|
||||
}
|
||||
|
||||
int days_in_month(int year, int month) {
|
||||
switch (month) {
|
||||
case 2:
|
||||
return is_leap_year(year) ? 29 : 28;
|
||||
case 4:
|
||||
case 6:
|
||||
case 9:
|
||||
case 11:
|
||||
return 30;
|
||||
default:
|
||||
return 31;
|
||||
}
|
||||
}
|
||||
|
||||
// Zeller-like algorithm for day of week (0 = Sunday)
|
||||
int __attribute__((noinline)) day_of_week(int year, int month, int day) {
|
||||
// Adjust for January/February
|
||||
if (month < 3) {
|
||||
month += 12;
|
||||
year--;
|
||||
}
|
||||
int k = year % 100;
|
||||
int j = year / 100;
|
||||
int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7;
|
||||
// Convert from Zeller (0=Sat) to standard (0=Sun)
|
||||
return ((h + 6) % 7);
|
||||
}
|
||||
|
||||
void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) {
|
||||
// Days since epoch
|
||||
int64_t days = epoch / 86400;
|
||||
int32_t remaining_secs = epoch % 86400;
|
||||
if (remaining_secs < 0) {
|
||||
days--;
|
||||
remaining_secs += 86400;
|
||||
}
|
||||
|
||||
out_tm->tm_sec = remaining_secs % 60;
|
||||
remaining_secs /= 60;
|
||||
out_tm->tm_min = remaining_secs % 60;
|
||||
out_tm->tm_hour = remaining_secs / 60;
|
||||
|
||||
// Day of week (Jan 1, 1970 was Thursday = 4)
|
||||
out_tm->tm_wday = static_cast<int>((days + 4) % 7);
|
||||
if (out_tm->tm_wday < 0)
|
||||
out_tm->tm_wday += 7;
|
||||
|
||||
// Calculate year (updates days to day-of-year)
|
||||
int year = days_to_year(days);
|
||||
out_tm->tm_year = year - 1900;
|
||||
out_tm->tm_yday = static_cast<int>(days);
|
||||
|
||||
// Calculate month and day
|
||||
int month = 1;
|
||||
int dim;
|
||||
while (days >= (dim = days_in_month(year, month))) {
|
||||
days -= dim;
|
||||
month++;
|
||||
}
|
||||
|
||||
out_tm->tm_mon = month - 1;
|
||||
out_tm->tm_mday = static_cast<int>(days) + 1;
|
||||
out_tm->tm_isdst = 0;
|
||||
}
|
||||
|
||||
bool skip_tz_name(const char *&p) {
|
||||
if (*p == '<') {
|
||||
// Angle-bracket quoted name: <+07>, <-03>, <AEST>
|
||||
p++; // skip '<'
|
||||
while (*p && *p != '>') {
|
||||
p++;
|
||||
}
|
||||
if (*p == '>') {
|
||||
p++; // skip '>'
|
||||
return true;
|
||||
}
|
||||
return false; // Unterminated
|
||||
}
|
||||
|
||||
// Standard name: 3+ letters
|
||||
const char *start = p;
|
||||
while (*p && std::isalpha(static_cast<unsigned char>(*p))) {
|
||||
p++;
|
||||
}
|
||||
return (p - start) >= 3;
|
||||
}
|
||||
|
||||
int32_t __attribute__((noinline)) parse_offset(const char *&p) {
|
||||
int sign = 1;
|
||||
if (*p == '-') {
|
||||
sign = -1;
|
||||
p++;
|
||||
} else if (*p == '+') {
|
||||
p++;
|
||||
}
|
||||
|
||||
int hours = parse_uint(p);
|
||||
int minutes = 0;
|
||||
int seconds = 0;
|
||||
|
||||
if (*p == ':') {
|
||||
p++;
|
||||
minutes = parse_uint(p);
|
||||
if (*p == ':') {
|
||||
p++;
|
||||
seconds = parse_uint(p);
|
||||
}
|
||||
}
|
||||
|
||||
return sign * (hours * 3600 + minutes * 60 + seconds);
|
||||
}
|
||||
|
||||
// Helper to parse the optional /time suffix (reuses parse_offset logic)
|
||||
static void parse_transition_time(const char *&p, DSTRule &rule) {
|
||||
rule.time_seconds = 2 * 3600; // Default 02:00
|
||||
if (*p == '/') {
|
||||
p++;
|
||||
rule.time_seconds = parse_offset(p);
|
||||
}
|
||||
}
|
||||
|
||||
void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) {
|
||||
// J format: day 1-365, Feb 29 is NOT counted even in leap years
|
||||
// So day 60 is always March 1
|
||||
// Iterate forward through months (no array needed)
|
||||
int remaining = julian_day;
|
||||
out_month = 1;
|
||||
while (out_month <= 12) {
|
||||
// Days in month for non-leap year (J format ignores leap years)
|
||||
int dim = days_in_month(2001, out_month); // 2001 is non-leap year
|
||||
if (remaining <= dim) {
|
||||
out_day = remaining;
|
||||
return;
|
||||
}
|
||||
remaining -= dim;
|
||||
out_month++;
|
||||
}
|
||||
out_day = remaining;
|
||||
}
|
||||
|
||||
void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) {
|
||||
// Plain format: day 0-365, Feb 29 IS counted in leap years
|
||||
// Day 0 = Jan 1
|
||||
int remaining = day_of_year;
|
||||
out_month = 1;
|
||||
|
||||
while (out_month <= 12) {
|
||||
int days_this_month = days_in_month(year, out_month);
|
||||
if (remaining < days_this_month) {
|
||||
out_day = remaining + 1;
|
||||
return;
|
||||
}
|
||||
remaining -= days_this_month;
|
||||
out_month++;
|
||||
}
|
||||
|
||||
// Shouldn't reach here with valid input
|
||||
out_month = 12;
|
||||
out_day = 31;
|
||||
}
|
||||
|
||||
bool parse_dst_rule(const char *&p, DSTRule &rule) {
|
||||
rule = {}; // Zero initialize
|
||||
|
||||
if (*p == 'M' || *p == 'm') {
|
||||
// M format: Mm.w.d (month.week.day)
|
||||
rule.type = DSTRuleType::MONTH_WEEK_DAY;
|
||||
p++;
|
||||
|
||||
rule.month = parse_uint(p);
|
||||
if (rule.month < 1 || rule.month > 12)
|
||||
return false;
|
||||
|
||||
if (*p++ != '.')
|
||||
return false;
|
||||
|
||||
rule.week = parse_uint(p);
|
||||
if (rule.week < 1 || rule.week > 5)
|
||||
return false;
|
||||
|
||||
if (*p++ != '.')
|
||||
return false;
|
||||
|
||||
rule.day_of_week = parse_uint(p);
|
||||
if (rule.day_of_week > 6)
|
||||
return false;
|
||||
|
||||
} else if (*p == 'J' || *p == 'j') {
|
||||
// J format: Jn (Julian day 1-365, not counting Feb 29)
|
||||
rule.type = DSTRuleType::JULIAN_NO_LEAP;
|
||||
p++;
|
||||
|
||||
rule.day = parse_uint(p);
|
||||
if (rule.day < 1 || rule.day > 365)
|
||||
return false;
|
||||
|
||||
} else if (std::isdigit(static_cast<unsigned char>(*p))) {
|
||||
// Plain number format: n (day 0-365, counting Feb 29)
|
||||
rule.type = DSTRuleType::DAY_OF_YEAR;
|
||||
|
||||
rule.day = parse_uint(p);
|
||||
if (rule.day > 365)
|
||||
return false;
|
||||
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse optional /time suffix
|
||||
parse_transition_time(p, rule);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate days from Jan 1 of given year to given month/day
|
||||
static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) {
|
||||
int days = day - 1;
|
||||
for (int m = 1; m < month; m++) {
|
||||
days += days_in_month(year, m);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
// Calculate days from epoch to Jan 1 of given year (for DST transition calculations)
|
||||
// Only supports years >= 1970. Timezone is either compiled in from YAML or set by
|
||||
// Home Assistant, so pre-1970 dates are not a concern.
|
||||
static int64_t __attribute__((noinline)) days_to_year_start(int year) {
|
||||
int64_t days = 0;
|
||||
for (int y = 1970; y < year; y++) {
|
||||
days += days_in_year(y);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) {
|
||||
int month, day;
|
||||
|
||||
switch (rule.type) {
|
||||
case DSTRuleType::MONTH_WEEK_DAY: {
|
||||
// Find the nth occurrence of day_of_week in the given month
|
||||
int first_dow = day_of_week(year, rule.month, 1);
|
||||
|
||||
// Days until first occurrence of target day
|
||||
int days_until_first = (rule.day_of_week - first_dow + 7) % 7;
|
||||
int first_occurrence = 1 + days_until_first;
|
||||
|
||||
if (rule.week == 5) {
|
||||
// "Last" occurrence - find the last one in the month
|
||||
int dim = days_in_month(year, rule.month);
|
||||
day = first_occurrence;
|
||||
while (day + 7 <= dim) {
|
||||
day += 7;
|
||||
}
|
||||
} else {
|
||||
// nth occurrence
|
||||
day = first_occurrence + (rule.week - 1) * 7;
|
||||
}
|
||||
month = rule.month;
|
||||
break;
|
||||
}
|
||||
|
||||
case DSTRuleType::JULIAN_NO_LEAP:
|
||||
// J format: day 1-365, Feb 29 not counted
|
||||
julian_to_month_day(rule.day, month, day);
|
||||
break;
|
||||
|
||||
case DSTRuleType::DAY_OF_YEAR:
|
||||
// Plain format: day 0-365, Feb 29 counted
|
||||
day_of_year_to_month_day(rule.day, year, month, day);
|
||||
break;
|
||||
|
||||
case DSTRuleType::NONE:
|
||||
// Should never be called with NONE, but handle it gracefully
|
||||
month = 1;
|
||||
day = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate days from epoch to this date
|
||||
int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day);
|
||||
|
||||
// Convert to epoch and add transition time and base offset
|
||||
return days * 86400 + rule.time_seconds + base_offset_seconds;
|
||||
}
|
||||
|
||||
} // namespace internal
|
||||
|
||||
bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) {
|
||||
if (!tz.has_dst()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int year = internal::epoch_to_year(utc_epoch);
|
||||
|
||||
// Calculate DST start and end for this year
|
||||
// DST start transition happens in standard time
|
||||
time_t dst_start = internal::calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds);
|
||||
// DST end transition happens in daylight time
|
||||
time_t dst_end = internal::calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds);
|
||||
|
||||
if (dst_start < dst_end) {
|
||||
// Northern hemisphere: DST is between start and end
|
||||
return (utc_epoch >= dst_start && utc_epoch < dst_end);
|
||||
} else {
|
||||
// Southern hemisphere: DST is outside the range (wraps around year)
|
||||
return (utc_epoch >= dst_start || utc_epoch < dst_end);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove before 2026.9.0: This parser is bridge code for backward compatibility with
|
||||
// older Home Assistant clients that send the timezone as a POSIX TZ string instead of
|
||||
// the pre-parsed ParsedTimezone protobuf struct. Once all clients send the struct
|
||||
// directly, this function and the parsing helpers above (skip_tz_name, parse_offset,
|
||||
// parse_dst_rule, parse_transition_time) can be removed.
|
||||
// See https://github.com/esphome/backlog/issues/91
|
||||
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) {
|
||||
if (!tz_string || !*tz_string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *p = tz_string;
|
||||
|
||||
// Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false)
|
||||
result.std_offset_seconds = 0;
|
||||
result.dst_offset_seconds = 0;
|
||||
result.dst_start = {};
|
||||
result.dst_end = {};
|
||||
|
||||
// Skip standard timezone name
|
||||
if (!internal::skip_tz_name(p)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse standard offset (required)
|
||||
if (!*p || (!std::isdigit(static_cast<unsigned char>(*p)) && *p != '+' && *p != '-')) {
|
||||
return false;
|
||||
}
|
||||
result.std_offset_seconds = internal::parse_offset(p);
|
||||
|
||||
// Check for DST name
|
||||
if (!*p) {
|
||||
return true; // No DST
|
||||
}
|
||||
|
||||
// If next char is comma, there's no DST name but there are rules (invalid)
|
||||
if (*p == ',') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's something that looks like a DST name start
|
||||
// (letter or angle bracket). If not, treat as trailing garbage and return success.
|
||||
if (!std::isalpha(static_cast<unsigned char>(*p)) && *p != '<') {
|
||||
return true; // No DST, trailing characters ignored
|
||||
}
|
||||
|
||||
if (!internal::skip_tz_name(p)) {
|
||||
return false; // Invalid DST name (started but malformed)
|
||||
}
|
||||
|
||||
// Optional DST offset (default is std - 1 hour)
|
||||
if (*p && *p != ',' && (std::isdigit(static_cast<unsigned char>(*p)) || *p == '+' || *p == '-')) {
|
||||
result.dst_offset_seconds = internal::parse_offset(p);
|
||||
} else {
|
||||
result.dst_offset_seconds = result.std_offset_seconds - 3600;
|
||||
}
|
||||
|
||||
// Parse DST rules (required when DST name is present)
|
||||
if (*p != ',') {
|
||||
// DST name without rules - treat as no DST since we can't determine transitions
|
||||
return true;
|
||||
}
|
||||
|
||||
p++;
|
||||
if (!internal::parse_dst_rule(p, result.dst_start)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Second rule is required per POSIX
|
||||
if (*p != ',') {
|
||||
return false;
|
||||
}
|
||||
p++;
|
||||
// has_dst() now returns true since dst_start.type was set by parse_dst_rule
|
||||
return internal::parse_dst_rule(p, result.dst_end);
|
||||
}
|
||||
|
||||
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) {
|
||||
if (!out_tm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine DST status once (avoids duplicate is_in_dst calculation)
|
||||
bool in_dst = is_in_dst(utc_epoch, tz);
|
||||
int32_t offset = in_dst ? tz.dst_offset_seconds : tz.std_offset_seconds;
|
||||
|
||||
// Apply offset (POSIX offset is positive west, so subtract to get local)
|
||||
time_t local_epoch = utc_epoch - offset;
|
||||
|
||||
internal::epoch_to_tm_utc(local_epoch, out_tm);
|
||||
out_tm->tm_isdst = in_dst ? 1 : 0;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::time
|
||||
|
||||
#ifndef USE_HOST
|
||||
// Override libc's localtime functions to use our timezone on embedded platforms.
|
||||
// This allows user lambdas calling ::localtime() to get correct local time
|
||||
// without needing the TZ environment variable (which pulls in scanf bloat).
|
||||
// On host, we use the normal TZ mechanism since there's no memory constraint.
|
||||
|
||||
// Thread-safe version
|
||||
extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) {
|
||||
if (timer == nullptr || result == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
esphome::time::epoch_to_local_tm(*timer, esphome::time::get_global_tz(), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Non-thread-safe version (uses static buffer, standard libc behavior)
|
||||
extern "C" struct tm *localtime(const time_t *timer) {
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static struct tm localtime_buf;
|
||||
return localtime_r(timer, &localtime_buf);
|
||||
}
|
||||
#endif // !USE_HOST
|
||||
|
||||
#endif // USE_TIME_TIMEZONE
|
||||
144
esphome/components/time/posix_tz.h
Normal file
144
esphome/components/time/posix_tz.h
Normal file
@@ -0,0 +1,144 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
/// Type of DST transition rule
|
||||
enum class DSTRuleType : uint8_t {
|
||||
NONE = 0, ///< No DST rule (used to indicate no DST)
|
||||
MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March)
|
||||
JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted)
|
||||
DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years)
|
||||
};
|
||||
|
||||
/// Rule for DST transition (packed for 32-bit: 12 bytes)
|
||||
struct DSTRule {
|
||||
int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM)
|
||||
uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR)
|
||||
DSTRuleType type; ///< Type of rule
|
||||
uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY)
|
||||
uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY)
|
||||
uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY)
|
||||
};
|
||||
|
||||
/// Parsed POSIX timezone information (packed for 32-bit: 32 bytes)
|
||||
struct ParsedTimezone {
|
||||
int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west)
|
||||
int32_t dst_offset_seconds; ///< DST offset from UTC in seconds
|
||||
DSTRule dst_start; ///< When DST starts
|
||||
DSTRule dst_end; ///< When DST ends
|
||||
|
||||
/// Check if this timezone has DST rules
|
||||
bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; }
|
||||
};
|
||||
|
||||
/// Parse a POSIX TZ string into a ParsedTimezone struct.
|
||||
///
|
||||
/// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility).
|
||||
/// This parser only exists so that older Home Assistant clients that send the timezone
|
||||
/// as a string (instead of the pre-parsed ParsedTimezone protobuf struct) can still
|
||||
/// set the timezone on the device. Once all clients are updated to send the struct
|
||||
/// directly, this function and all internal parsing helpers will be removed.
|
||||
/// See https://github.com/esphome/backlog/issues/91
|
||||
///
|
||||
/// Supports formats like:
|
||||
/// - "EST5" (simple offset, no DST)
|
||||
/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules)
|
||||
/// - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times)
|
||||
/// - "<+07>-7" (angle-bracket notation for special names)
|
||||
/// - "IST-5:30" (half-hour offsets)
|
||||
/// - "EST5EDT,J60,J300" (J-format: Julian day without leap day)
|
||||
/// - "EST5EDT,60,300" (plain day number: day of year with leap day)
|
||||
/// @param tz_string The POSIX TZ string to parse
|
||||
/// @param result Output: the parsed timezone data
|
||||
/// @return true if parsing succeeded, false on error
|
||||
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result);
|
||||
|
||||
/// Convert a UTC epoch to local time using the parsed timezone.
|
||||
/// This replaces libc's localtime() to avoid scanf dependency.
|
||||
/// @param utc_epoch Unix timestamp in UTC
|
||||
/// @param tz The parsed timezone
|
||||
/// @param[out] out_tm Output tm struct with local time
|
||||
/// @return true on success
|
||||
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm);
|
||||
|
||||
/// Set the global timezone used by epoch_to_local_tm() when called without a timezone.
|
||||
/// This is called by RealTimeClock::apply_timezone_() to enable ESPTime::from_epoch_local()
|
||||
/// to work without libc's localtime().
|
||||
void set_global_tz(const ParsedTimezone &tz);
|
||||
|
||||
/// Get the global timezone.
|
||||
const ParsedTimezone &get_global_tz();
|
||||
|
||||
/// Check if a given UTC epoch falls within DST for the parsed timezone.
|
||||
/// @param utc_epoch Unix timestamp in UTC
|
||||
/// @param tz The parsed timezone
|
||||
/// @return true if DST is in effect at the given time
|
||||
bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz);
|
||||
|
||||
// Internal helper functions exposed for testing.
|
||||
// Remove before 2026.9.0: skip_tz_name, parse_offset, parse_dst_rule are only
|
||||
// used by parse_posix_tz() which is bridge code for backward compatibility.
|
||||
// The remaining helpers (epoch_to_tm_utc, day_of_week, days_in_month, etc.)
|
||||
// are used by the conversion functions and will stay.
|
||||
|
||||
namespace internal {
|
||||
|
||||
/// Skip a timezone name (letters or <...> quoted format)
|
||||
/// @param p Pointer to current position, updated on return
|
||||
/// @return true if a valid name was found
|
||||
bool skip_tz_name(const char *&p);
|
||||
|
||||
/// Parse an offset in format [-]hh[:mm[:ss]]
|
||||
/// @param p Pointer to current position, updated on return
|
||||
/// @return Offset in seconds
|
||||
int32_t parse_offset(const char *&p);
|
||||
|
||||
/// Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time]
|
||||
/// @param p Pointer to current position, updated on return
|
||||
/// @param rule Output: the parsed rule
|
||||
/// @return true if parsing succeeded
|
||||
bool parse_dst_rule(const char *&p, DSTRule &rule);
|
||||
|
||||
/// Convert Julian day (J format, 1-365 not counting Feb 29) to month/day
|
||||
/// @param julian_day Day number 1-365
|
||||
/// @param[out] month Output: month 1-12
|
||||
/// @param[out] day Output: day of month
|
||||
void julian_to_month_day(int julian_day, int &month, int &day);
|
||||
|
||||
/// Convert day of year (plain format, 0-365 counting Feb 29) to month/day
|
||||
/// @param day_of_year Day number 0-365
|
||||
/// @param year The year (for leap year calculation)
|
||||
/// @param[out] month Output: month 1-12
|
||||
/// @param[out] day Output: day of month
|
||||
void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day);
|
||||
|
||||
/// Calculate day of week for any date (0 = Sunday)
|
||||
/// Uses a simplified algorithm that works for years 1970-2099
|
||||
int day_of_week(int year, int month, int day);
|
||||
|
||||
/// Get the number of days in a month
|
||||
int days_in_month(int year, int month);
|
||||
|
||||
/// Check if a year is a leap year
|
||||
bool is_leap_year(int year);
|
||||
|
||||
/// Convert epoch to year/month/day/hour/min/sec (UTC)
|
||||
void epoch_to_tm_utc(time_t epoch, struct tm *out_tm);
|
||||
|
||||
/// Calculate the epoch timestamp for a DST transition in a given year.
|
||||
/// @param year The year (e.g., 2026)
|
||||
/// @param rule The DST rule (month, week, day_of_week, time)
|
||||
/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context)
|
||||
/// @return Unix epoch timestamp of the transition
|
||||
time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds);
|
||||
|
||||
} // namespace internal
|
||||
|
||||
} // namespace esphome::time
|
||||
|
||||
#endif // USE_TIME_TIMEZONE
|
||||
@@ -14,8 +14,8 @@
|
||||
#include <sys/time.h>
|
||||
#endif
|
||||
#include <cerrno>
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
@@ -23,9 +23,33 @@ static const char *const TAG = "time";
|
||||
|
||||
RealTimeClock::RealTimeClock() = default;
|
||||
|
||||
ESPTime __attribute__((noinline)) RealTimeClock::now() {
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
time_t epoch = this->timestamp_now();
|
||||
struct tm local_tm;
|
||||
if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) {
|
||||
return ESPTime::from_c_tm(&local_tm, epoch);
|
||||
}
|
||||
// Fallback to UTC if parsing failed
|
||||
return ESPTime::from_epoch_utc(epoch);
|
||||
#else
|
||||
return ESPTime::from_epoch_local(this->timestamp_now());
|
||||
#endif
|
||||
}
|
||||
|
||||
void RealTimeClock::dump_config() {
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str());
|
||||
const auto &tz = get_global_tz();
|
||||
// POSIX offset is positive west, negate for conventional UTC+X display
|
||||
int std_h = -tz.std_offset_seconds / 3600;
|
||||
int std_m = (std::abs(tz.std_offset_seconds) % 3600) / 60;
|
||||
if (tz.has_dst()) {
|
||||
int dst_h = -tz.dst_offset_seconds / 3600;
|
||||
int dst_m = (std::abs(tz.dst_offset_seconds) % 3600) / 60;
|
||||
ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d (DST UTC%+d:%02d)", std_h, std_m, dst_h, dst_m);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_h, std_m);
|
||||
}
|
||||
#endif
|
||||
auto time = this->now();
|
||||
ESP_LOGCONFIG(TAG, "Current time: %04d-%02d-%02d %02d:%02d:%02d", time.year, time.month, time.day_of_month, time.hour,
|
||||
@@ -72,11 +96,6 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
|
||||
ret = settimeofday(&timev, nullptr);
|
||||
}
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
// Move timezone back to local timezone.
|
||||
this->apply_timezone_();
|
||||
#endif
|
||||
|
||||
if (ret != 0) {
|
||||
ESP_LOGW(TAG, "setimeofday() failed with code %d", ret);
|
||||
}
|
||||
@@ -89,9 +108,33 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
|
||||
}
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
void RealTimeClock::apply_timezone_() {
|
||||
setenv("TZ", this->timezone_.c_str(), 1);
|
||||
void RealTimeClock::apply_timezone_(const char *tz) {
|
||||
ParsedTimezone parsed{};
|
||||
|
||||
// Handle null or empty input - use UTC
|
||||
if (tz == nullptr || *tz == '\0') {
|
||||
// Skip if already UTC
|
||||
if (!get_global_tz().has_dst() && get_global_tz().std_offset_seconds == 0) {
|
||||
return;
|
||||
}
|
||||
set_global_tz(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_HOST
|
||||
// On host platform, also set TZ environment variable for libc compatibility
|
||||
setenv("TZ", tz, 1);
|
||||
tzset();
|
||||
#endif
|
||||
|
||||
// Parse the POSIX TZ string using our custom parser
|
||||
if (!parse_posix_tz(tz, parsed)) {
|
||||
ESP_LOGW(TAG, "Failed to parse timezone: %s", tz);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set global timezone for all time conversions
|
||||
set_global_tz(parsed);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/time.h"
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
#include "posix_tz.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::time {
|
||||
|
||||
@@ -20,26 +23,31 @@ class RealTimeClock : public PollingComponent {
|
||||
explicit RealTimeClock();
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
/// Set the time zone.
|
||||
void set_timezone(const std::string &tz) {
|
||||
this->timezone_ = tz;
|
||||
this->apply_timezone_();
|
||||
}
|
||||
/// Set the time zone from a POSIX TZ string.
|
||||
void set_timezone(const char *tz) { this->apply_timezone_(tz); }
|
||||
|
||||
/// Set the time zone from raw buffer, only if it differs from the current one.
|
||||
/// Set the time zone from a character buffer with known length.
|
||||
/// The buffer does not need to be null-terminated.
|
||||
void set_timezone(const char *tz, size_t len) {
|
||||
if (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) {
|
||||
this->timezone_.assign(tz, len);
|
||||
this->apply_timezone_();
|
||||
if (tz == nullptr) {
|
||||
this->apply_timezone_(nullptr);
|
||||
return;
|
||||
}
|
||||
// Stack buffer - TZ strings from tzdata are typically short (< 50 chars)
|
||||
char buf[128];
|
||||
if (len >= sizeof(buf))
|
||||
len = sizeof(buf) - 1;
|
||||
memcpy(buf, tz, len);
|
||||
buf[len] = '\0';
|
||||
this->apply_timezone_(buf);
|
||||
}
|
||||
|
||||
/// Get the time zone currently in use.
|
||||
std::string get_timezone() { return this->timezone_; }
|
||||
/// Set the time zone from a std::string.
|
||||
void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); }
|
||||
#endif
|
||||
|
||||
/// Get the time in the currently defined timezone.
|
||||
ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
|
||||
ESPTime now();
|
||||
|
||||
/// Get the time without any time zone or DST corrections.
|
||||
ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); }
|
||||
@@ -58,8 +66,7 @@ class RealTimeClock : public PollingComponent {
|
||||
void synchronize_epoch_(uint32_t epoch);
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
std::string timezone_{};
|
||||
void apply_timezone_();
|
||||
void apply_timezone_(const char *tz);
|
||||
#endif
|
||||
|
||||
LazyCallbackManager<void()> time_sync_callback_;
|
||||
|
||||
@@ -115,8 +115,8 @@ void RP2040UartComponent::setup() {
|
||||
|
||||
if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) {
|
||||
ESP_LOGV(TAG, "Using SerialPIO");
|
||||
pin_size_t tx = this->tx_pin_ == nullptr ? SerialPIO::NOPIN : this->tx_pin_->get_pin();
|
||||
pin_size_t rx = this->rx_pin_ == nullptr ? SerialPIO::NOPIN : this->rx_pin_->get_pin();
|
||||
pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin();
|
||||
pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin();
|
||||
auto *serial = new SerialPIO(tx, rx, this->rx_buffer_size_); // NOLINT(cppcoreguidelines-owning-memory)
|
||||
serial->begin(this->baud_rate_, config);
|
||||
if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted())
|
||||
|
||||
@@ -14,8 +14,6 @@ ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource
|
||||
ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es)
|
||||
: web_server_(ws), events_(es) {}
|
||||
#endif
|
||||
ListEntitiesIterator::~ListEntitiesIterator() {}
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) {
|
||||
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator);
|
||||
|
||||
@@ -17,14 +17,13 @@ class DeferredUpdateEventSource;
|
||||
#endif
|
||||
class WebServer;
|
||||
|
||||
class ListEntitiesIterator : public ComponentIterator {
|
||||
class ListEntitiesIterator final : public ComponentIterator {
|
||||
public:
|
||||
#ifdef USE_ESP32
|
||||
ListEntitiesIterator(const WebServer *ws, esphome::web_server_idf::AsyncEventSource *es);
|
||||
#elif defined(USE_ARDUINO)
|
||||
ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es);
|
||||
#endif
|
||||
virtual ~ListEntitiesIterator();
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool on_binary_sensor(binary_sensor::BinarySensor *obj) override;
|
||||
#endif
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace esphome::web_server {
|
||||
|
||||
class WebServerOTAComponent : public ota::OTAComponent {
|
||||
class WebServerOTAComponent final : public ota::OTAComponent {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
namespace esphome::web_server {
|
||||
|
||||
#ifdef USE_WEBSERVER_GZIP
|
||||
const uint8_t INDEX_GZ[] PROGMEM = {
|
||||
constexpr uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xed, 0x7d, 0xd9, 0x72, 0xdb, 0x48, 0xb6, 0xe0, 0xf3,
|
||||
0xd4, 0x57, 0x40, 0x28, 0xb5, 0x8c, 0x2c, 0x26, 0xc1, 0x45, 0x92, 0x2d, 0x83, 0x4a, 0xb2, 0x65, 0xd9, 0xd5, 0x76,
|
||||
0x97, 0xb7, 0xb6, 0xec, 0xda, 0x58, 0x6c, 0x09, 0x02, 0x92, 0x44, 0x96, 0x41, 0x80, 0x05, 0x24, 0xb5, 0x14, 0x89,
|
||||
@@ -698,7 +698,7 @@ const uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0x01, 0x65, 0x21, 0x07, 0x4b, 0xe3, 0x97, 0x00, 0x00};
|
||||
|
||||
#else // Brotli (default, smaller)
|
||||
const uint8_t INDEX_BR[] PROGMEM = {
|
||||
constexpr uint8_t INDEX_BR[] PROGMEM = {
|
||||
0x1b, 0xe2, 0x97, 0xa3, 0x90, 0xa2, 0x95, 0x55, 0x51, 0x04, 0x1b, 0x07, 0x80, 0x20, 0x79, 0x0e, 0x50, 0xab, 0x02,
|
||||
0xdb, 0x98, 0x16, 0xf4, 0x7b, 0x22, 0xa3, 0x4d, 0xd3, 0x86, 0xc1, 0x26, 0x48, 0x49, 0x60, 0xbe, 0xb3, 0xc9, 0xa1,
|
||||
0x8c, 0x96, 0x10, 0x1b, 0x21, 0xcf, 0x48, 0x68, 0xce, 0x10, 0x34, 0x32, 0x7c, 0xbf, 0x71, 0x7b, 0x03, 0x8f, 0xdd,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
namespace esphome::web_server {
|
||||
|
||||
#ifdef USE_WEBSERVER_GZIP
|
||||
const uint8_t INDEX_GZ[] PROGMEM = {
|
||||
constexpr uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xcc, 0xbd, 0x7b, 0x7f, 0x1a, 0xb9, 0xb2, 0x28, 0xfa,
|
||||
0xf7, 0x3d, 0x9f, 0xc2, 0xee, 0x9d, 0xf1, 0xb4, 0x8c, 0x68, 0x03, 0x36, 0x8e, 0xd3, 0x58, 0xe6, 0xe4, 0x39, 0xc9,
|
||||
0x3c, 0x92, 0x4c, 0x9c, 0x64, 0x26, 0xc3, 0xb0, 0x33, 0xa2, 0x11, 0xa0, 0xa4, 0x91, 0x98, 0x96, 0x88, 0xed, 0x01,
|
||||
@@ -4107,7 +4107,7 @@ const uint8_t INDEX_GZ[] PROGMEM = {
|
||||
0xe8, 0xcd, 0xfe, 0x2c, 0x9d, 0x07, 0xfd, 0xff, 0x05, 0x64, 0x23, 0xa6, 0xdb, 0x06, 0x7b, 0x03, 0x00};
|
||||
|
||||
#else // Brotli (default, smaller)
|
||||
const uint8_t INDEX_BR[] PROGMEM = {
|
||||
constexpr uint8_t INDEX_BR[] PROGMEM = {
|
||||
0x5b, 0x05, 0x7b, 0x53, 0xc1, 0xb6, 0x69, 0x3d, 0x41, 0xeb, 0x04, 0x30, 0xf6, 0xd6, 0x77, 0x35, 0xdb, 0xa3, 0x08,
|
||||
0x36, 0x0e, 0x04, 0x80, 0x90, 0x4f, 0xf1, 0xb2, 0x21, 0xa4, 0x82, 0xee, 0x00, 0xaa, 0x20, 0x7f, 0x3b, 0xff, 0x00,
|
||||
0xaa, 0x9a, 0x73, 0x74, 0x8c, 0xe1, 0xa6, 0x1f, 0xa0, 0xa2, 0x59, 0xf5, 0xaa, 0x92, 0x79, 0x50, 0x43, 0x1f, 0xe8,
|
||||
|
||||
@@ -107,7 +107,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE };
|
||||
using message_generator_t = json::SerializationBuffer<>(WebServer *, void *);
|
||||
|
||||
class DeferredUpdateEventSourceList;
|
||||
class DeferredUpdateEventSource : public AsyncEventSource {
|
||||
class DeferredUpdateEventSource final : public AsyncEventSource {
|
||||
friend class DeferredUpdateEventSourceList;
|
||||
|
||||
/*
|
||||
@@ -163,7 +163,7 @@ class DeferredUpdateEventSource : public AsyncEventSource {
|
||||
void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0);
|
||||
};
|
||||
|
||||
class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource *> {
|
||||
class DeferredUpdateEventSourceList final : public std::list<DeferredUpdateEventSource *> {
|
||||
protected:
|
||||
void on_client_connect_(DeferredUpdateEventSource *source);
|
||||
void on_client_disconnect_(DeferredUpdateEventSource *source);
|
||||
@@ -187,7 +187,7 @@ class DeferredUpdateEventSourceList : public std::list<DeferredUpdateEventSource
|
||||
* under the '/light/...', '/sensor/...', ... URLs. A full documentation for this API
|
||||
* can be found under https://esphome.io/web-api/.
|
||||
*/
|
||||
class WebServer : public Controller, public Component, public AsyncWebHandler {
|
||||
class WebServer final : public Controller, public Component, public AsyncWebHandler {
|
||||
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
||||
friend class DeferredUpdateEventSourceList;
|
||||
#endif
|
||||
|
||||
@@ -921,7 +921,7 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c
|
||||
});
|
||||
|
||||
// Use heap buffer - 1460 bytes is too large for the httpd task stack
|
||||
auto buffer = std::make_unique<char[]>(MULTIPART_CHUNK_SIZE);
|
||||
auto buffer = std::make_unique_for_overwrite<char[]>(MULTIPART_CHUNK_SIZE);
|
||||
size_t bytes_since_yield = 0;
|
||||
|
||||
for (size_t remaining = r->content_len; remaining > 0;) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cassert>
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
#include <type_traits>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1)
|
||||
@@ -1334,20 +1335,61 @@ void WiFiComponent::start_scanning() {
|
||||
// Using insertion sort instead of std::stable_sort saves flash memory
|
||||
// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
|
||||
// IMPORTANT: This sort is stable (preserves relative order of equal elements)
|
||||
//
|
||||
// Uses raw memcpy instead of copy assignment to avoid CompactString's
|
||||
// destructor/constructor overhead (heap delete[]/new[] for long SSIDs).
|
||||
// Copy assignment calls ~CompactString() then placement-new for every shift,
|
||||
// which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+
|
||||
// networks (e.g., captive portal showing full scan results), this caused
|
||||
// event loop blocking from hundreds of heap operations in a tight loop.
|
||||
//
|
||||
// This is safe because we're permuting elements within the same array —
|
||||
// each slot is overwritten exactly once, so no ownership duplication occurs.
|
||||
// All members of WiFiScanResult are either trivially copyable (bssid, channel,
|
||||
// rssi, priority, flags) or CompactString, which stores either inline data or
|
||||
// a heap pointer — never a self-referential pointer (unlike std::string's SSO
|
||||
// on some implementations). This was not possible before PR#13472 replaced
|
||||
// std::string with CompactString, since std::string's internal layout is
|
||||
// implementation-defined and may use self-referential pointers.
|
||||
//
|
||||
// TODO: If C++ standardizes std::trivially_relocatable, add the assertion for
|
||||
// WiFiScanResult/CompactString here to formally express the memcpy safety guarantee.
|
||||
template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
|
||||
// memcpy-based sort requires no self-referential pointers or virtual dispatch.
|
||||
// These static_asserts guard the assumptions. If any fire, the memcpy sort
|
||||
// must be reviewed for safety before updating the expected values.
|
||||
//
|
||||
// No vtable pointers (memcpy would corrupt vptr)
|
||||
static_assert(!std::is_polymorphic<WiFiScanResult>::value, "WiFiScanResult must not have vtable");
|
||||
static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
|
||||
// Standard layout ensures predictable memory layout with no virtual bases
|
||||
// and no mixed-access-specifier reordering
|
||||
static_assert(std::is_standard_layout<WiFiScanResult>::value, "WiFiScanResult must be standard layout");
|
||||
static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
|
||||
// Size checks catch added/removed fields that may need safety review
|
||||
static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe");
|
||||
static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe");
|
||||
// Alignment must match for reinterpret_cast of key_buf to be valid
|
||||
static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t");
|
||||
const size_t size = results.size();
|
||||
constexpr size_t elem_size = sizeof(WiFiScanResult);
|
||||
// Suppress warnings for intentional memcpy on non-trivially-copyable type.
|
||||
// Safety is guaranteed by the static_asserts above and the permutation invariant.
|
||||
// NOLINTNEXTLINE(bugprone-undefined-memory-manipulation)
|
||||
auto *memcpy_fn = &memcpy;
|
||||
for (size_t i = 1; i < size; i++) {
|
||||
// Make a copy to avoid issues with move semantics during comparison
|
||||
WiFiScanResult key = results[i];
|
||||
alignas(WiFiScanResult) uint8_t key_buf[elem_size];
|
||||
memcpy_fn(key_buf, &results[i], elem_size);
|
||||
const auto &key = *reinterpret_cast<const WiFiScanResult *>(key_buf);
|
||||
int32_t j = i - 1;
|
||||
|
||||
// Move elements that are worse than key to the right
|
||||
// For stability, we only move if key is strictly better than results[j]
|
||||
while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
|
||||
results[j + 1] = results[j];
|
||||
memcpy_fn(&results[j + 1], &results[j], elem_size);
|
||||
j--;
|
||||
}
|
||||
results[j + 1] = key;
|
||||
memcpy_fn(&results[j + 1], key_buf, elem_size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
@@ -223,6 +224,14 @@ class CompactString {
|
||||
};
|
||||
|
||||
static_assert(sizeof(CompactString) == 20, "CompactString must be exactly 20 bytes");
|
||||
// CompactString is not trivially copyable (non-trivial destructor/copy for heap case).
|
||||
// However, its layout has no self-referential pointers: storage_[] contains either inline
|
||||
// data or an external heap pointer — never a pointer to itself. This is unlike libstdc++
|
||||
// std::string SSO where _M_p points to _M_local_buf within the same object.
|
||||
// This property allows memcpy-based permutation sorting where each element ends up in
|
||||
// exactly one slot (no ownership duplication). These asserts document that layout property.
|
||||
static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
|
||||
static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
|
||||
|
||||
class WiFiAP {
|
||||
friend class WiFiComponent;
|
||||
|
||||
@@ -78,8 +78,13 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
|
||||
return false;
|
||||
#endif
|
||||
|
||||
auto ret = WiFi.begin(ap.ssid_.c_str(), ap.password_.c_str());
|
||||
if (ret != WL_CONNECTED)
|
||||
// Use beginNoBlock to avoid WiFi.begin()'s additional 2x timeout wait loop on top of
|
||||
// CYW43::begin()'s internal blocking join. CYW43::begin() blocks for up to 10 seconds
|
||||
// (default timeout) to complete the join - this is required because the LwipIntfDev netif
|
||||
// setup depends on begin() succeeding. beginNoBlock() skips the outer wait loop, saving
|
||||
// up to 20 additional seconds of blocking per attempt.
|
||||
auto ret = WiFi.beginNoBlock(ap.ssid_.c_str(), ap.password_.c_str());
|
||||
if (ret == WL_IDLE_STATUS)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
@@ -116,13 +121,19 @@ const char *get_disconnect_reason_str(uint8_t reason) {
|
||||
}
|
||||
|
||||
WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() {
|
||||
int status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
|
||||
// Use cyw43_wifi_link_status instead of cyw43_tcpip_link_status because the Arduino
|
||||
// framework's __wrap_cyw43_cb_tcpip_init is a no-op — the SDK's internal netif
|
||||
// (cyw43_state.netif[]) is never initialized. cyw43_tcpip_link_status checks that netif's
|
||||
// flags and would only fall through to cyw43_wifi_link_status when the flags aren't set.
|
||||
// Using cyw43_wifi_link_status directly gives us the actual WiFi radio join state.
|
||||
int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA);
|
||||
switch (status) {
|
||||
case CYW43_LINK_JOIN:
|
||||
case CYW43_LINK_NOIP:
|
||||
// WiFi joined, check if we have an IP address via the Arduino framework's WiFi class
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
return WiFiSTAConnectStatus::CONNECTED;
|
||||
}
|
||||
return WiFiSTAConnectStatus::CONNECTING;
|
||||
case CYW43_LINK_UP:
|
||||
return WiFiSTAConnectStatus::CONNECTED;
|
||||
case CYW43_LINK_FAIL:
|
||||
case CYW43_LINK_BADAUTH:
|
||||
return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED;
|
||||
@@ -139,18 +150,24 @@ int WiFiComponent::s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *r
|
||||
|
||||
void WiFiComponent::wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result) {
|
||||
s_scan_result_count++;
|
||||
const char *ssid_cstr = reinterpret_cast<const char *>(result->ssid);
|
||||
|
||||
// CYW43 scan results have ssid as a 32-byte buffer that is NOT null-terminated.
|
||||
// Use ssid_len to create a properly terminated copy for string operations.
|
||||
uint8_t len = std::min(result->ssid_len, static_cast<uint8_t>(sizeof(result->ssid)));
|
||||
char ssid_buf[33]; // 32 max + null terminator
|
||||
memcpy(ssid_buf, result->ssid, len);
|
||||
ssid_buf[len] = '\0';
|
||||
|
||||
// Skip networks that don't match any configured network (unless full results needed)
|
||||
if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_cstr, result->bssid)) {
|
||||
this->log_discarded_scan_result_(ssid_cstr, result->bssid, result->rssi, result->channel);
|
||||
if (!this->needs_full_scan_results_() && !this->matches_configured_network_(ssid_buf, result->bssid)) {
|
||||
this->log_discarded_scan_result_(ssid_buf, result->bssid, result->rssi, result->channel);
|
||||
return;
|
||||
}
|
||||
|
||||
bssid_t bssid;
|
||||
std::copy(result->bssid, result->bssid + 6, bssid.begin());
|
||||
WiFiScanResult res(bssid, ssid_cstr, strlen(ssid_cstr), result->channel, result->rssi,
|
||||
result->auth_mode != CYW43_AUTH_OPEN, ssid_cstr[0] == '\0');
|
||||
WiFiScanResult res(bssid, ssid_buf, len, result->channel, result->rssi, result->auth_mode != CYW43_AUTH_OPEN,
|
||||
len == 0);
|
||||
if (std::find(this->scan_result_.begin(), this->scan_result_.end(), res) == this->scan_result_.end()) {
|
||||
this->scan_result_.push_back(res);
|
||||
}
|
||||
@@ -167,7 +184,6 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
ESP_LOGV(TAG, "cyw43_wifi_scan failed");
|
||||
}
|
||||
return err == 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
@@ -212,8 +228,10 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *
|
||||
#endif // USE_WIFI_AP
|
||||
|
||||
bool WiFiComponent::wifi_disconnect_() {
|
||||
int err = cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
|
||||
return err == 0;
|
||||
// Use Arduino WiFi.disconnect() instead of raw cyw43_wifi_leave() to properly
|
||||
// clean up the lwIP netif, DHCP client, and internal Arduino state.
|
||||
WiFi.disconnect();
|
||||
return true;
|
||||
}
|
||||
|
||||
bssid_t WiFiComponent::wifi_bssid() {
|
||||
@@ -269,9 +287,10 @@ void WiFiComponent::wifi_loop_() {
|
||||
|
||||
// Poll for connection state changes
|
||||
// The arduino-pico WiFi library doesn't have event callbacks like ESP8266/ESP32,
|
||||
// so we need to poll the link status to detect state changes
|
||||
auto status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
|
||||
bool is_connected = (status == CYW43_LINK_UP);
|
||||
// so we need to poll the link status to detect state changes.
|
||||
// Use WiFi.connected() which checks both the WiFi link and IP address via the
|
||||
// Arduino framework's own netif (not the SDK's uninitialized one).
|
||||
bool is_connected = WiFi.connected();
|
||||
|
||||
// Detect connection state change
|
||||
if (is_connected && !s_sta_was_connected) {
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#endif
|
||||
#ifdef USE_ESP32
|
||||
#include <esp_chip_info.h>
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#endif
|
||||
#include "esphome/core/version.h"
|
||||
#include "esphome/core/hal.h"
|
||||
@@ -144,8 +147,14 @@ void Application::setup() {
|
||||
clear_setup_priority_overrides();
|
||||
#endif
|
||||
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
// Set up wake socket for waking main loop from tasks
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32)
|
||||
// Initialize fast select: saves main loop task handle for xTaskNotifyGive wake.
|
||||
// Always init on ESP32 — the fast path (rcvevent reads + ulTaskNotifyTake) is used
|
||||
// unconditionally when USE_SOCKET_SELECT_SUPPORT is enabled.
|
||||
esphome_lwip_fast_select_init();
|
||||
#endif
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
// Set up wake socket for waking main loop from tasks (non-ESP32 only)
|
||||
this->setup_wake_loop_threadsafe_();
|
||||
#endif
|
||||
|
||||
@@ -523,7 +532,7 @@ void Application::enable_pending_loops_() {
|
||||
}
|
||||
|
||||
void Application::before_loop_tasks_(uint32_t loop_start_time) {
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
// Drain wake notifications first to clear socket for next wake
|
||||
this->drain_wake_notifications_();
|
||||
#endif
|
||||
@@ -576,11 +585,15 @@ bool Application::register_socket_fd(int fd) {
|
||||
#endif
|
||||
|
||||
this->socket_fds_.push_back(fd);
|
||||
#ifdef USE_ESP32
|
||||
// Hook the socket's netconn callback for instant wake on receive events
|
||||
esphome_lwip_hook_socket(fd);
|
||||
#else
|
||||
this->socket_fds_changed_ = true;
|
||||
|
||||
if (fd > this->max_fd_) {
|
||||
this->max_fd_ = fd;
|
||||
}
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -595,12 +608,14 @@ void Application::unregister_socket_fd(int fd) {
|
||||
if (this->socket_fds_[i] != fd)
|
||||
continue;
|
||||
|
||||
// Swap with last element and pop - O(1) removal since order doesn't matter
|
||||
// Swap with last element and pop - O(1) removal since order doesn't matter.
|
||||
// No need to unhook the netconn callback on ESP32 — all LwIP sockets share
|
||||
// the same static event_callback, and the socket will be closed by the caller.
|
||||
if (i < this->socket_fds_.size() - 1)
|
||||
this->socket_fds_[i] = this->socket_fds_.back();
|
||||
this->socket_fds_.pop_back();
|
||||
#ifndef USE_ESP32
|
||||
this->socket_fds_changed_ = true;
|
||||
|
||||
// Only recalculate max_fd if we removed the current max
|
||||
if (fd == this->max_fd_) {
|
||||
this->max_fd_ = -1;
|
||||
@@ -609,6 +624,7 @@ void Application::unregister_socket_fd(int fd) {
|
||||
this->max_fd_ = sock_fd;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -616,16 +632,41 @@ void Application::unregister_socket_fd(int fd) {
|
||||
#endif
|
||||
|
||||
void Application::yield_with_select_(uint32_t delay_ms) {
|
||||
// Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run
|
||||
// since select() with 0 timeout only polls without yielding.
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
if (!this->socket_fds_.empty()) {
|
||||
// Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run.
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32)
|
||||
// ESP32 fast path: reads rcvevent directly via lwip_socket_dbg_get_socket() (~215 ns per socket).
|
||||
// Safe because this runs on the main loop which owns socket lifetime (create, read, close).
|
||||
if (delay_ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any socket already has pending data before sleeping.
|
||||
// If a socket still has unread data (rcvevent > 0) but the task notification was already
|
||||
// consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency.
|
||||
// This scan preserves select() semantics: return immediately when any fd is ready.
|
||||
for (int fd : this->socket_fds_) {
|
||||
if (esphome_lwip_socket_has_data(fd)) {
|
||||
yield();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep with instant wake via FreeRTOS task notification.
|
||||
// Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout.
|
||||
// Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task —
|
||||
// background tasks won't call wake, so this degrades to a pure timeout (same as old select path).
|
||||
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms));
|
||||
|
||||
#elif defined(USE_SOCKET_SELECT_SUPPORT)
|
||||
// Non-ESP32 select() path (LibreTiny bk72xx/rtl87xx, host platform).
|
||||
// ESP32 is excluded by the #if above — both BSD_SOCKETS and LWIP_SOCKETS on ESP32
|
||||
// use LwIP under the hood, so the fast path handles all ESP32 socket implementations.
|
||||
if (!this->socket_fds_.empty()) [[likely]] {
|
||||
// Update fd_set if socket list has changed
|
||||
if (this->socket_fds_changed_) {
|
||||
if (this->socket_fds_changed_) [[unlikely]] {
|
||||
FD_ZERO(&this->base_read_fds_);
|
||||
// fd bounds are already validated in register_socket_fd() or guaranteed by platform design:
|
||||
// - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS)
|
||||
// - Other platforms: register_socket_fd() validates fd < FD_SETSIZE
|
||||
// fd bounds are validated in register_socket_fd()
|
||||
for (int fd : this->socket_fds_) {
|
||||
FD_SET(fd, &this->base_read_fds_);
|
||||
}
|
||||
@@ -641,7 +682,7 @@ void Application::yield_with_select_(uint32_t delay_ms) {
|
||||
tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000;
|
||||
|
||||
// Call select with timeout
|
||||
#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS))
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
|
||||
int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
|
||||
#else
|
||||
int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv);
|
||||
@@ -651,19 +692,18 @@ void Application::yield_with_select_(uint32_t delay_ms) {
|
||||
// ret < 0: error (except EINTR which is normal)
|
||||
// ret > 0: socket(s) have data ready - normal and expected
|
||||
// ret == 0: timeout occurred - normal and expected
|
||||
if (ret < 0 && errno != EINTR) {
|
||||
// Actual error - log and fall back to delay
|
||||
ESP_LOGW(TAG, "select() failed with errno %d", errno);
|
||||
delay(delay_ms);
|
||||
if (ret >= 0 || errno == EINTR) [[likely]] {
|
||||
// Yield if zero timeout since select(0) only polls without yielding
|
||||
if (delay_ms == 0) [[unlikely]] {
|
||||
yield();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// When delay_ms is 0, we need to yield since select(0) doesn't yield
|
||||
if (delay_ms == 0) {
|
||||
yield();
|
||||
}
|
||||
} else {
|
||||
// No sockets registered, use regular delay
|
||||
delay(delay_ms);
|
||||
// select() error - log and fall through to delay()
|
||||
ESP_LOGW(TAG, "select() failed with errno %d", errno);
|
||||
}
|
||||
// No sockets registered or select() failed - use regular delay
|
||||
delay(delay_ms);
|
||||
#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
|
||||
// No select support but can wake on socket activity via esp_schedule()
|
||||
socket::socket_delay(delay_ms);
|
||||
@@ -673,9 +713,25 @@ void Application::yield_with_select_(uint32_t delay_ms) {
|
||||
#endif
|
||||
}
|
||||
|
||||
Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
// App storage — asm label shares the linker symbol with "extern Application App".
|
||||
// char[] is trivially destructible, so no __cxa_atexit or destructor chain is emitted.
|
||||
// Constructed via placement new in the generated setup().
|
||||
#ifndef __GXX_ABI_VERSION
|
||||
#error "Application placement new requires Itanium C++ ABI (GCC/Clang)"
|
||||
#endif
|
||||
static_assert(std::is_default_constructible<Application>::value, "Application must be default-constructible");
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
alignas(Application) char app_storage[sizeof(Application)] asm("_ZN7esphome3AppE");
|
||||
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
|
||||
#ifdef USE_ESP32
|
||||
void Application::wake_loop_threadsafe() {
|
||||
// Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe)
|
||||
esphome_lwip_wake_main_loop();
|
||||
}
|
||||
#else // !USE_ESP32
|
||||
|
||||
void Application::setup_wake_loop_threadsafe_() {
|
||||
// Create UDP socket for wake notifications
|
||||
this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
@@ -742,6 +798,8 @@ void Application::wake_loop_threadsafe() {
|
||||
lwip_send(this->wake_socket_fd_, &dummy, 1, 0);
|
||||
}
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
|
||||
void Application::get_build_time_string(std::span<char, BUILD_TIME_STR_SIZE> buffer) {
|
||||
|
||||
@@ -24,10 +24,14 @@
|
||||
#endif
|
||||
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#else
|
||||
#include <sys/select.h>
|
||||
#ifdef USE_WAKE_LOOP_THREADSAFE
|
||||
#include <lwip/sockets.h>
|
||||
#endif
|
||||
#endif
|
||||
#endif // USE_SOCKET_SELECT_SUPPORT
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -491,15 +495,12 @@ class Application {
|
||||
/// @return true if registration was successful, false if fd exceeds limits
|
||||
bool register_socket_fd(int fd);
|
||||
void unregister_socket_fd(int fd);
|
||||
/// Check if there's data available on a socket without blocking
|
||||
/// This function is thread-safe for reading, but should be called after select() has run
|
||||
/// The read_fds_ is only modified by select() in the main loop
|
||||
bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); }
|
||||
|
||||
#ifdef USE_WAKE_LOOP_THREADSAFE
|
||||
/// Wake the main event loop from a FreeRTOS task
|
||||
/// Thread-safe, can be called from task context to immediately wake select()
|
||||
/// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe)
|
||||
/// Wake the main event loop from another FreeRTOS task.
|
||||
/// Thread-safe, but must only be called from task context (NOT ISR-safe).
|
||||
/// On ESP32: uses xTaskNotifyGive (<1 us)
|
||||
/// On other platforms: uses UDP loopback socket
|
||||
void wake_loop_threadsafe();
|
||||
#endif
|
||||
#endif
|
||||
@@ -510,10 +511,14 @@ class Application {
|
||||
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
/// Fast path for Socket::ready() via friendship - skips negative fd check.
|
||||
/// Safe because: fd was validated in register_socket_fd() at registration time,
|
||||
/// and Socket::ready() only calls this when loop_monitored_ is true (registration succeeded).
|
||||
/// FD_ISSET may include its own upper bounds check depending on platform.
|
||||
/// Main loop only — on ESP32, reads rcvevent via lwip_socket_dbg_get_socket()
|
||||
/// which has no refcount; safe only because the main loop owns socket lifetime
|
||||
/// (creates, reads, and closes sockets on the same thread).
|
||||
#ifdef USE_ESP32
|
||||
bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); }
|
||||
#else
|
||||
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
|
||||
#endif
|
||||
#endif
|
||||
|
||||
void register_component_(Component *comp);
|
||||
@@ -541,7 +546,7 @@ class Application {
|
||||
/// Perform a delay while also monitoring socket file descriptors for readiness
|
||||
void yield_with_select_(uint32_t delay_ms);
|
||||
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
void setup_wake_loop_threadsafe_(); // Create wake notification socket
|
||||
inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined)
|
||||
#endif
|
||||
@@ -571,7 +576,7 @@ class Application {
|
||||
FixedVector<Component *> looping_components_{};
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
|
||||
#ifdef USE_WAKE_LOOP_THREADSAFE
|
||||
#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks
|
||||
#endif
|
||||
#endif
|
||||
@@ -584,7 +589,7 @@ class Application {
|
||||
uint32_t last_loop_{0};
|
||||
uint32_t loop_component_start_time_{0};
|
||||
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
|
||||
int max_fd_{-1}; // Highest file descriptor number for select()
|
||||
#endif
|
||||
|
||||
@@ -600,14 +605,14 @@ class Application {
|
||||
bool in_loop_{false};
|
||||
volatile bool has_pending_enable_loop_requests_{false};
|
||||
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
|
||||
bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes
|
||||
#endif
|
||||
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
// Variable-sized members
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32)
|
||||
// Variable-sized members (not needed on ESP32 — is_socket_ready_ reads rcvevent directly)
|
||||
fd_set read_fds_{}; // Working fd_set: populated by select()
|
||||
fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes
|
||||
fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_
|
||||
#endif
|
||||
|
||||
// StaticVectors (largest members - contain actual array data inline)
|
||||
@@ -694,7 +699,7 @@ class Application {
|
||||
/// Global storage of Application pointer - only one Application can exist.
|
||||
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
// Inline implementations for hot-path functions
|
||||
// drain_wake_notifications_() is called on every loop iteration
|
||||
|
||||
@@ -704,8 +709,8 @@ static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16;
|
||||
|
||||
inline void Application::drain_wake_notifications_() {
|
||||
// Called from main loop to drain any pending wake notifications
|
||||
// Must check is_socket_ready() to avoid blocking on empty socket
|
||||
if (this->wake_socket_fd_ >= 0 && this->is_socket_ready(this->wake_socket_fd_)) {
|
||||
// Must check is_socket_ready_() to avoid blocking on empty socket
|
||||
if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->wake_socket_fd_)) {
|
||||
char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE];
|
||||
// Drain all pending notifications with non-blocking reads
|
||||
// Multiple wake events may have triggered multiple writes, so drain until EWOULDBLOCK
|
||||
@@ -716,6 +721,6 @@ inline void Application::drain_wake_notifications_() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE)
|
||||
#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -512,6 +512,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_global(cg.RawExpression("using std::min"))
|
||||
cg.add_global(cg.RawExpression("using std::max"))
|
||||
|
||||
# Construct App via placement new — see application.cpp for storage details
|
||||
cg.add_global(cg.RawStatement("#include <new>"))
|
||||
cg.add(cg.RawExpression("new (&App) Application()"))
|
||||
cg.add(
|
||||
cg.App.pre_setup(
|
||||
config[CONF_NAME],
|
||||
|
||||
216
esphome/core/lwip_fast_select.c
Normal file
216
esphome/core/lwip_fast_select.c
Normal file
@@ -0,0 +1,216 @@
|
||||
// Fast socket monitoring for ESP32 (ESP-IDF LwIP)
|
||||
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
|
||||
//
|
||||
// This must be a .c file (not .cpp) because:
|
||||
// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers
|
||||
// 2. The netconn callback is a C function pointer
|
||||
//
|
||||
// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc.
|
||||
//
|
||||
// Thread safety analysis
|
||||
// ======================
|
||||
// Three threads interact with this code:
|
||||
// 1. Main loop task — calls init, has_data, hook
|
||||
// 2. LwIP TCP/IP task — calls event_callback (reads s_original_callback; writes rcvevent
|
||||
// via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex)
|
||||
// 3. Background tasks — call wake_main_loop
|
||||
//
|
||||
// LwIP source references (ESP-IDF v5.5.2, commit 30aaf64524):
|
||||
// sockets.c: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/api/sockets.c
|
||||
// - event_callback (static, same for all sockets): L327
|
||||
// - DEFAULT_SOCKET_EVENTCB = event_callback: L328
|
||||
// - tryget_socket_unconn_nouse (direct array lookup): L450
|
||||
// - lwip_socket_dbg_get_socket (thin wrapper): L461
|
||||
// - All socket types use DEFAULT_SOCKET_EVENTCB: L1741, L1748, L1759
|
||||
// - event_callback definition: L2538
|
||||
// - SYS_ARCH_PROTECT before rcvevent switch: L2578
|
||||
// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): L2582
|
||||
// - SYS_ARCH_UNPROTECT after switch: L2615
|
||||
// sys.h: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/include/lwip/sys.h
|
||||
// - SYS_ARCH_PROTECT calls sys_arch_protect(): L495
|
||||
// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): L506
|
||||
// (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock)
|
||||
//
|
||||
// Socket slot lifetime
|
||||
// ====================
|
||||
// This code reads struct lwip_sock fields without SYS_ARCH_PROTECT. The safety
|
||||
// argument requires that the slot cannot be freed while we read it.
|
||||
//
|
||||
// In LwIP, the socket table is a static array and slots are only freed via:
|
||||
// lwip_close() -> lwip_close_internal() -> free_socket_free_elements() -> free_socket()
|
||||
// The TCP/IP thread does NOT call free_socket(). On link loss, RST, or timeout
|
||||
// it frees the TCP PCB and signals the netconn (rcvevent++ to indicate EOF), but
|
||||
// the netconn and lwip_sock slot remain allocated until the application calls
|
||||
// lwip_close(). ESPHome removes the fd from the monitored set before calling
|
||||
// lwip_close().
|
||||
//
|
||||
// Therefore lwip_socket_dbg_get_socket(fd) plus a volatile read of rcvevent
|
||||
// (to prevent compiler reordering or caching) is safe as long as the application
|
||||
// is single-writer for close. ESPHome guarantees this by design: all socket
|
||||
// create/read/close happens on the main loop. fd numbers are not reused while
|
||||
// the slot remains allocated, and the slot remains allocated until lwip_close().
|
||||
// Any change in LwIP that allows free_socket() to be called outside lwip_close()
|
||||
// would invalidate this assumption.
|
||||
//
|
||||
// LwIP source references for slot lifetime:
|
||||
// sockets.c (same commit as above):
|
||||
// - alloc_socket (slot allocation): L419
|
||||
// - free_socket (slot deallocation): L384
|
||||
// - free_socket_free_elements (called from lwip_close_internal): L393
|
||||
// - lwip_close_internal (only caller of free_socket_free_elements): L2355
|
||||
// - lwip_close (only caller of lwip_close_internal): L2450
|
||||
//
|
||||
// Shared state and safety rationale:
|
||||
//
|
||||
// s_main_loop_task (TaskHandle_t, 4 bytes):
|
||||
// Written once by main loop in init(). Read by TCP/IP thread (in callback)
|
||||
// and background tasks (in wake).
|
||||
// Safe: write-once-then-read pattern. Socket hooks may run before init(),
|
||||
// but the NULL check on s_main_loop_task in the callback provides correct
|
||||
// degraded behavior — notifications are simply skipped until init() completes.
|
||||
//
|
||||
// s_original_callback (netconn_callback, 4-byte function pointer):
|
||||
// Written by main loop in hook_socket() (only when NULL — set once).
|
||||
// Read by TCP/IP thread in esphome_socket_event_callback().
|
||||
// Safe: set-once pattern. The first hook_socket() captures the original callback.
|
||||
// All subsequent hooks see it already set and skip the write. The TCP/IP thread
|
||||
// only reads this after the callback pointer has been swapped (which happens after
|
||||
// the write), so it always sees the initialized value.
|
||||
//
|
||||
// sock->conn->callback (netconn_callback, 4-byte function pointer):
|
||||
// Written by main loop in hook_socket(). Never restored — all LwIP sockets share
|
||||
// the same static event_callback (DEFAULT_SOCKET_EVENTCB), so the wrapper stays permanently.
|
||||
// Read by TCP/IP thread when invoking the callback.
|
||||
// Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32).
|
||||
// The TCP/IP thread will see either the old or new pointer atomically — never a
|
||||
// torn value. Both the wrapper and original callbacks are valid at all times
|
||||
// (the wrapper itself calls the original), so either value is correct.
|
||||
//
|
||||
// sock->rcvevent (s16_t, 2 bytes):
|
||||
// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT.
|
||||
// Read by main loop in has_data() via volatile cast.
|
||||
// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex, which internally
|
||||
// uses a critical section with memory barrier (rsync on dual-core Xtensa; on
|
||||
// single-core builds the spinlock is compiled out, but cross-core visibility is
|
||||
// not an issue). The volatile cast prevents the compiler
|
||||
// from caching the read. Aligned 16-bit reads are single-instruction loads on
|
||||
// Xtensa (L16SI) and RISC-V (LH), which cannot produce torn values.
|
||||
//
|
||||
// FreeRTOS task notification value:
|
||||
// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks
|
||||
// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake).
|
||||
// Safe: FreeRTOS notification APIs are thread-safe by design (use internal
|
||||
// critical sections). Multiple concurrent xTaskNotifyGive calls are safe —
|
||||
// the notification count simply increments.
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc.
|
||||
#include <lwip/api.h>
|
||||
#include <lwip/priv/sockets_priv.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
// Compile-time verification of thread safety assumptions.
|
||||
// On ESP32 (Xtensa/RISC-V), naturally-aligned reads/writes up to 32 bits are atomic.
|
||||
// These asserts ensure our cross-thread shared state meets those requirements.
|
||||
|
||||
// Pointer types must fit in a single 32-bit store (atomic write)
|
||||
_Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access");
|
||||
_Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access");
|
||||
|
||||
// rcvevent must fit in a single atomic read
|
||||
_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access");
|
||||
|
||||
// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V.
|
||||
// Misaligned access would not be atomic even if the size is <= 4 bytes.
|
||||
_Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == 0,
|
||||
"netconn.callback must be naturally aligned for atomic access");
|
||||
_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0,
|
||||
"lwip_sock.rcvevent must be naturally aligned for atomic access");
|
||||
|
||||
// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks.
|
||||
static TaskHandle_t s_main_loop_task = NULL;
|
||||
|
||||
// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task.
|
||||
static netconn_callback s_original_callback = NULL;
|
||||
|
||||
// Wrapper callback: calls original event_callback + notifies main loop task.
|
||||
// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR).
|
||||
static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) {
|
||||
// Call original LwIP event_callback first — updates rcvevent/sendevent/errevent,
|
||||
// signals any select() waiters. This preserves all LwIP behavior.
|
||||
// s_original_callback is always valid here: hook_socket() sets it before swapping
|
||||
// the callback pointer, so this wrapper cannot run until it's initialized.
|
||||
s_original_callback(conn, evt, len);
|
||||
// Wake the main loop task if sleeping in ulTaskNotifyTake().
|
||||
// Only notify on receive events to avoid spurious wakeups from send-ready events.
|
||||
// NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS
|
||||
// (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
|
||||
// already wake the main loop through the RCVPLUS path.
|
||||
if (evt == NETCONN_EVT_RCVPLUS) {
|
||||
TaskHandle_t task = s_main_loop_task;
|
||||
if (task != NULL) {
|
||||
xTaskNotifyGive(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); }
|
||||
|
||||
// lwip_socket_dbg_get_socket() is a thin wrapper around the static
|
||||
// tryget_socket_unconn_nouse() — a direct array lookup without the refcount
|
||||
// that get_socket()/done_socket() uses. This is safe because:
|
||||
// 1. The only path to free_socket() is lwip_close(), called exclusively from the main loop
|
||||
// 2. The TCP/IP thread never frees socket slots (see "Socket slot lifetime" above)
|
||||
// 3. Both has_data() reads and lwip_close() run on the main loop — no concurrent free
|
||||
// If lwip_socket_dbg_get_socket() were ever removed, we could fall back to lwip_select().
|
||||
// Returns the sock only if both the sock and its netconn are valid, NULL otherwise.
|
||||
static inline struct lwip_sock *get_sock(int fd) {
|
||||
struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd);
|
||||
if (sock == NULL || sock->conn == NULL)
|
||||
return NULL;
|
||||
return sock;
|
||||
}
|
||||
|
||||
bool esphome_lwip_socket_has_data(int fd) {
|
||||
struct lwip_sock *sock = get_sock(fd);
|
||||
if (sock == NULL)
|
||||
return false;
|
||||
// volatile prevents the compiler from caching/reordering this cross-thread read.
|
||||
// The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a
|
||||
// FreeRTOS mutex with a memory barrier (rsync on Xtensa), ensuring the value is
|
||||
// visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH) on
|
||||
// Xtensa/RISC-V and cannot produce torn values.
|
||||
return *(volatile s16_t *) &sock->rcvevent > 0;
|
||||
}
|
||||
|
||||
void esphome_lwip_hook_socket(int fd) {
|
||||
struct lwip_sock *sock = get_sock(fd);
|
||||
if (sock == NULL)
|
||||
return;
|
||||
|
||||
// Save original callback once — all LwIP sockets share the same static event_callback
|
||||
// (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM).
|
||||
if (s_original_callback == NULL) {
|
||||
s_original_callback = sock->conn->callback;
|
||||
}
|
||||
|
||||
// Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write).
|
||||
// TCP/IP thread sees either old or new pointer — both are valid.
|
||||
sock->conn->callback = esphome_socket_event_callback;
|
||||
}
|
||||
|
||||
// Wake the main loop from another FreeRTOS task. NOT ISR-safe.
|
||||
void esphome_lwip_wake_main_loop(void) {
|
||||
TaskHandle_t task = s_main_loop_task;
|
||||
if (task != NULL) {
|
||||
xTaskNotifyGive(task);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // USE_ESP32
|
||||
33
esphome/core/lwip_fast_select.h
Normal file
33
esphome/core/lwip_fast_select.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
// Fast socket monitoring for ESP32 (ESP-IDF LwIP)
|
||||
// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications.
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/// Initialize fast select — must be called from the main loop task during setup().
|
||||
/// Saves the current task handle for xTaskNotifyGive() wake notifications.
|
||||
void esphome_lwip_fast_select_init(void);
|
||||
|
||||
/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket).
|
||||
/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that
|
||||
/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime:
|
||||
/// both has_data reads and socket close/unregister happen on the main loop thread.
|
||||
bool esphome_lwip_socket_has_data(int fd);
|
||||
|
||||
/// Hook a socket's netconn callback to notify the main loop task on receive events.
|
||||
/// Wraps the original event_callback with one that also calls xTaskNotifyGive().
|
||||
/// Must be called from the main loop after socket creation.
|
||||
void esphome_lwip_hook_socket(int fd);
|
||||
|
||||
/// Wake the main loop task from another FreeRTOS task — costs <1 us.
|
||||
/// NOT ISR-safe — must only be called from task context.
|
||||
void esphome_lwip_wake_main_loop(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -2,7 +2,6 @@
|
||||
#include "helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -67,58 +66,123 @@ std::string ESPTime::strftime(const char *format) {
|
||||
|
||||
std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); }
|
||||
|
||||
bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) {
|
||||
uint16_t year;
|
||||
uint8_t month;
|
||||
uint8_t day;
|
||||
uint8_t hour;
|
||||
uint8_t minute;
|
||||
uint8_t second;
|
||||
int num;
|
||||
const int ilen = static_cast<int>(len);
|
||||
|
||||
if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT
|
||||
&hour, // NOLINT
|
||||
&minute, // NOLINT
|
||||
&second, &num) == 6 && // NOLINT
|
||||
num == ilen) {
|
||||
esp_time.year = year;
|
||||
esp_time.month = month;
|
||||
esp_time.day_of_month = day;
|
||||
esp_time.hour = hour;
|
||||
esp_time.minute = minute;
|
||||
esp_time.second = second;
|
||||
} else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT
|
||||
&hour, // NOLINT
|
||||
&minute, &num) == 5 && // NOLINT
|
||||
num == ilen) {
|
||||
esp_time.year = year;
|
||||
esp_time.month = month;
|
||||
esp_time.day_of_month = day;
|
||||
esp_time.hour = hour;
|
||||
esp_time.minute = minute;
|
||||
esp_time.second = 0;
|
||||
} else if (sscanf(time_to_parse, "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT
|
||||
num == ilen) {
|
||||
esp_time.hour = hour;
|
||||
esp_time.minute = minute;
|
||||
esp_time.second = second;
|
||||
} else if (sscanf(time_to_parse, "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT
|
||||
num == ilen) {
|
||||
esp_time.hour = hour;
|
||||
esp_time.minute = minute;
|
||||
esp_time.second = 0;
|
||||
} else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT
|
||||
num == ilen) {
|
||||
esp_time.year = year;
|
||||
esp_time.month = month;
|
||||
esp_time.day_of_month = day;
|
||||
} else {
|
||||
return false;
|
||||
// Helper to parse exactly N digits, returns false if not enough digits
|
||||
static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) {
|
||||
value = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (p >= end || *p < '0' || *p > '9')
|
||||
return false;
|
||||
value = value * 10 + (*p - '0');
|
||||
p++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper to check for expected character
|
||||
static bool expect_char(const char *&p, const char *end, char expected) {
|
||||
if (p >= end || *p != expected)
|
||||
return false;
|
||||
p++;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) {
|
||||
// Supported formats:
|
||||
// YYYY-MM-DD HH:MM:SS (19 chars)
|
||||
// YYYY-MM-DD HH:MM (16 chars)
|
||||
// YYYY-MM-DD (10 chars)
|
||||
// HH:MM:SS (8 chars)
|
||||
// HH:MM (5 chars)
|
||||
|
||||
if (time_to_parse == nullptr || len == 0)
|
||||
return false;
|
||||
|
||||
const char *p = time_to_parse;
|
||||
const char *end = time_to_parse + len;
|
||||
uint16_t v1, v2, v3, v4, v5, v6;
|
||||
|
||||
// Try date formats first (start with 4-digit year)
|
||||
if (len >= 10 && time_to_parse[4] == '-') {
|
||||
// YYYY-MM-DD...
|
||||
if (!parse_digits(p, end, 4, v1))
|
||||
return false;
|
||||
if (!expect_char(p, end, '-'))
|
||||
return false;
|
||||
if (!parse_digits(p, end, 2, v2))
|
||||
return false;
|
||||
if (!expect_char(p, end, '-'))
|
||||
return false;
|
||||
if (!parse_digits(p, end, 2, v3))
|
||||
return false;
|
||||
|
||||
esp_time.year = v1;
|
||||
esp_time.month = v2;
|
||||
esp_time.day_of_month = v3;
|
||||
|
||||
if (p == end) {
|
||||
// YYYY-MM-DD (date only)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!expect_char(p, end, ' '))
|
||||
return false;
|
||||
|
||||
// Continue with time part: HH:MM[:SS]
|
||||
if (!parse_digits(p, end, 2, v4))
|
||||
return false;
|
||||
if (!expect_char(p, end, ':'))
|
||||
return false;
|
||||
if (!parse_digits(p, end, 2, v5))
|
||||
return false;
|
||||
|
||||
esp_time.hour = v4;
|
||||
esp_time.minute = v5;
|
||||
|
||||
if (p == end) {
|
||||
// YYYY-MM-DD HH:MM
|
||||
esp_time.second = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!expect_char(p, end, ':'))
|
||||
return false;
|
||||
if (!parse_digits(p, end, 2, v6))
|
||||
return false;
|
||||
|
||||
esp_time.second = v6;
|
||||
return p == end; // YYYY-MM-DD HH:MM:SS
|
||||
}
|
||||
|
||||
// Try time-only formats (HH:MM[:SS])
|
||||
if (len >= 5 && time_to_parse[2] == ':') {
|
||||
if (!parse_digits(p, end, 2, v1))
|
||||
return false;
|
||||
if (!expect_char(p, end, ':'))
|
||||
return false;
|
||||
if (!parse_digits(p, end, 2, v2))
|
||||
return false;
|
||||
|
||||
esp_time.hour = v1;
|
||||
esp_time.minute = v2;
|
||||
|
||||
if (p == end) {
|
||||
// HH:MM
|
||||
esp_time.second = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!expect_char(p, end, ':'))
|
||||
return false;
|
||||
if (!parse_digits(p, end, 2, v3))
|
||||
return false;
|
||||
|
||||
esp_time.second = v3;
|
||||
return p == end; // HH:MM:SS
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void ESPTime::increment_second() {
|
||||
this->timestamp++;
|
||||
if (!increment_time_value(this->second, 0, 60))
|
||||
@@ -193,27 +257,67 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) {
|
||||
}
|
||||
|
||||
void ESPTime::recalc_timestamp_local() {
|
||||
struct tm tm;
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
// Calculate timestamp as if fields were UTC
|
||||
this->recalc_timestamp_utc(false);
|
||||
if (this->timestamp == -1) {
|
||||
return; // Invalid time
|
||||
}
|
||||
|
||||
tm.tm_year = this->year - 1900;
|
||||
tm.tm_mon = this->month - 1;
|
||||
tm.tm_mday = this->day_of_month;
|
||||
tm.tm_hour = this->hour;
|
||||
tm.tm_min = this->minute;
|
||||
tm.tm_sec = this->second;
|
||||
tm.tm_isdst = -1;
|
||||
// Now convert from local to UTC by adding the offset
|
||||
// POSIX: local = utc - offset, so utc = local + offset
|
||||
const auto &tz = time::get_global_tz();
|
||||
|
||||
this->timestamp = mktime(&tm);
|
||||
if (!tz.has_dst()) {
|
||||
// No DST - just apply standard offset
|
||||
this->timestamp += tz.std_offset_seconds;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try both interpretations to match libc mktime() with tm_isdst=-1
|
||||
// For ambiguous times (fall-back repeated hour), prefer standard time
|
||||
// For invalid times (spring-forward skipped hour), libc normalizes forward
|
||||
time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds;
|
||||
time_t utc_if_std = this->timestamp + tz.std_offset_seconds;
|
||||
|
||||
bool dst_valid = time::is_in_dst(utc_if_dst, tz);
|
||||
bool std_valid = !time::is_in_dst(utc_if_std, tz);
|
||||
|
||||
if (dst_valid && std_valid) {
|
||||
// Ambiguous time (repeated hour during fall-back) - prefer standard time
|
||||
this->timestamp = utc_if_std;
|
||||
} else if (dst_valid) {
|
||||
// Only DST interpretation is valid
|
||||
this->timestamp = utc_if_dst;
|
||||
} else if (std_valid) {
|
||||
// Only standard interpretation is valid
|
||||
this->timestamp = utc_if_std;
|
||||
} else {
|
||||
// Invalid time (skipped hour during spring-forward)
|
||||
// libc normalizes forward: 02:30 CST -> 08:30 UTC -> 03:30 CDT
|
||||
// Using std offset achieves this since the UTC result falls during DST
|
||||
this->timestamp = utc_if_std;
|
||||
}
|
||||
#else
|
||||
// No timezone support - treat as UTC
|
||||
this->recalc_timestamp_utc(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
int32_t ESPTime::timezone_offset() {
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
time_t now = ::time(nullptr);
|
||||
struct tm local_tm = *::localtime(&now);
|
||||
local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset.
|
||||
time_t local_time = mktime(&local_tm);
|
||||
struct tm utc_tm = *::gmtime(&now);
|
||||
time_t utc_time = mktime(&utc_tm);
|
||||
return static_cast<int32_t>(local_time - utc_time);
|
||||
const auto &tz = time::get_global_tz();
|
||||
// POSIX offset is positive west, but we return offset to add to UTC to get local
|
||||
// So we negate the POSIX offset
|
||||
if (time::is_in_dst(now, tz)) {
|
||||
return -tz.dst_offset_seconds;
|
||||
}
|
||||
return -tz.std_offset_seconds;
|
||||
#else
|
||||
// No timezone support - no offset
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; }
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
#include <span>
|
||||
#include <string>
|
||||
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
#include "esphome/components/time/posix_tz.h"
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
|
||||
template<typename T> bool increment_time_value(T ¤t, uint16_t begin, uint16_t end);
|
||||
@@ -105,11 +109,17 @@ struct ESPTime {
|
||||
* @return The generated ESPTime
|
||||
*/
|
||||
static ESPTime from_epoch_local(time_t epoch) {
|
||||
struct tm *c_tm = ::localtime(&epoch);
|
||||
if (c_tm == nullptr) {
|
||||
return ESPTime{}; // Return an invalid ESPTime
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
struct tm local_tm;
|
||||
if (time::epoch_to_local_tm(epoch, time::get_global_tz(), &local_tm)) {
|
||||
return ESPTime::from_c_tm(&local_tm, epoch);
|
||||
}
|
||||
return ESPTime::from_c_tm(c_tm, epoch);
|
||||
// Fallback to UTC if conversion failed
|
||||
return ESPTime::from_epoch_utc(epoch);
|
||||
#else
|
||||
// No timezone support - return UTC (no TZ configured, localtime would return UTC anyway)
|
||||
return ESPTime::from_epoch_utc(epoch);
|
||||
#endif
|
||||
}
|
||||
/** Convert an UTC epoch timestamp to a UTC time ESPTime instance.
|
||||
*
|
||||
|
||||
@@ -193,10 +193,10 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script
|
||||
extends = common:arduino
|
||||
board_build.filesystem_size = 0.5m
|
||||
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.2.0-gcc12
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460
|
||||
platform_packages =
|
||||
; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted
|
||||
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/3.9.4/rp2040-3.9.4.zip
|
||||
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.0/rp2040-5.5.0.zip
|
||||
|
||||
framework = arduino
|
||||
lib_deps =
|
||||
|
||||
@@ -12,7 +12,7 @@ platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.1.7
|
||||
esphome-dashboard==20260210.0
|
||||
aioesphomeapi==44.1.0
|
||||
aioesphomeapi==44.2.0
|
||||
zeroconf==0.148.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pylint==4.0.5
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.2 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.3 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
pre-commit
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ def create_test_config(config_name: str, includes: list[str]) -> dict:
|
||||
],
|
||||
"build_flags": [
|
||||
"-Og", # optimize for debug
|
||||
"-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing
|
||||
],
|
||||
"debug_build_flags": [ # only for debug builds
|
||||
"-g3", # max debug info
|
||||
|
||||
@@ -3,10 +3,13 @@ esp_ldo:
|
||||
channel: 3
|
||||
voltage: 2.5V
|
||||
adjustable: true
|
||||
- id: ldo_4
|
||||
- id: ldo_4_passthrough
|
||||
channel: 4
|
||||
voltage: 2.0V
|
||||
setup_priority: 900
|
||||
voltage: passthrough
|
||||
- id: ldo_1_internal
|
||||
channel: 1
|
||||
voltage: 1.8V
|
||||
allow_internal_channel: true
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
|
||||
1
tests/components/socket/test.bk72xx-ard.yaml
Normal file
1
tests/components/socket/test.bk72xx-ard.yaml
Normal file
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
1
tests/components/socket/test.ln882x-ard.yaml
Normal file
1
tests/components/socket/test.ln882x-ard.yaml
Normal file
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
1
tests/components/socket/test.rtl87xx-ard.yaml
Normal file
1
tests/components/socket/test.rtl87xx-ard.yaml
Normal file
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -1,9 +1,21 @@
|
||||
from esphome.components import socket
|
||||
from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def _setup_platform(platform=PLATFORM_ESP8266) -> None:
|
||||
"""Set up CORE.data with a platform for testing."""
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
|
||||
|
||||
|
||||
def test_require_wake_loop_threadsafe__first_call() -> None:
|
||||
"""Test that first call sets up define and consumes socket."""
|
||||
_setup_platform()
|
||||
CORE.config = {"wifi": True}
|
||||
socket.require_wake_loop_threadsafe()
|
||||
|
||||
@@ -32,6 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None:
|
||||
|
||||
def test_require_wake_loop_threadsafe__multiple_calls() -> None:
|
||||
"""Test that multiple calls only set up once."""
|
||||
_setup_platform()
|
||||
# Call three times
|
||||
CORE.config = {"openthread": True}
|
||||
socket.require_wake_loop_threadsafe()
|
||||
@@ -75,3 +88,29 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -
|
||||
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
|
||||
assert "socket.wake_loop_threadsafe" not in udp_consumers
|
||||
assert udp_consumers == initial_udp
|
||||
|
||||
|
||||
def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None:
|
||||
"""Test that ESP32 uses task notifications instead of UDP socket."""
|
||||
_setup_platform(PLATFORM_ESP32)
|
||||
CORE.config = {"wifi": True}
|
||||
socket.require_wake_loop_threadsafe()
|
||||
|
||||
# Verify the define was added
|
||||
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
|
||||
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
|
||||
|
||||
# Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications)
|
||||
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
|
||||
assert "socket.wake_loop_threadsafe" not in udp_consumers
|
||||
|
||||
|
||||
def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None:
|
||||
"""Test that non-ESP32 platforms consume a UDP socket for wake notifications."""
|
||||
_setup_platform(PLATFORM_ESP8266)
|
||||
CORE.config = {"wifi": True}
|
||||
socket.require_wake_loop_threadsafe()
|
||||
|
||||
# Verify UDP socket was consumed
|
||||
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
|
||||
assert udp_consumers.get("socket.wake_loop_threadsafe") == 1
|
||||
|
||||
1275
tests/components/time/posix_tz_parser.cpp
Normal file
1275
tests/components/time/posix_tz_parser.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,11 @@ sensor:
|
||||
id: source_sensor_4
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: template
|
||||
name: "Source Sensor 5"
|
||||
id: source_sensor_5
|
||||
accuracy_decimals: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor_1
|
||||
name: "Filter Min"
|
||||
@@ -69,6 +74,13 @@ sensor:
|
||||
filters:
|
||||
- delta: 0
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor_5
|
||||
name: "Filter Percentage"
|
||||
id: filter_percentage
|
||||
filters:
|
||||
- delta: 50%
|
||||
|
||||
script:
|
||||
- id: test_filter_min
|
||||
then:
|
||||
@@ -154,6 +166,28 @@ script:
|
||||
id: source_sensor_4
|
||||
state: 2.0
|
||||
|
||||
- id: test_filter_percentage
|
||||
then:
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_5
|
||||
state: 100.0
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_5
|
||||
state: 120.0 # Filtered out (delta=20, need >50)
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_5
|
||||
state: 160.0 # Passes (delta=60 > 50% of 100=50)
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_5
|
||||
state: 200.0 # Filtered out (delta=40, need >50% of 160=80)
|
||||
- delay: 20ms
|
||||
- sensor.template.publish:
|
||||
id: source_sensor_5
|
||||
state: 250.0 # Passes (delta=90 > 80)
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Test Filter Min"
|
||||
@@ -178,3 +212,9 @@ button:
|
||||
id: btn_filter_zero_delta
|
||||
on_press:
|
||||
- script.execute: test_filter_zero_delta
|
||||
|
||||
- platform: template
|
||||
name: "Test Filter Percentage"
|
||||
id: btn_filter_percentage
|
||||
on_press:
|
||||
- script.execute: test_filter_percentage
|
||||
|
||||
@@ -24,12 +24,14 @@ async def test_sensor_filters_delta(
|
||||
"filter_max": [],
|
||||
"filter_baseline_max": [],
|
||||
"filter_zero_delta": [],
|
||||
"filter_percentage": [],
|
||||
}
|
||||
|
||||
filter_min_done = loop.create_future()
|
||||
filter_max_done = loop.create_future()
|
||||
filter_baseline_max_done = loop.create_future()
|
||||
filter_zero_delta_done = loop.create_future()
|
||||
filter_percentage_done = loop.create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if not isinstance(state, SensorState) or state.missing_state:
|
||||
@@ -66,6 +68,12 @@ async def test_sensor_filters_delta(
|
||||
and not filter_zero_delta_done.done()
|
||||
):
|
||||
filter_zero_delta_done.set_result(True)
|
||||
elif (
|
||||
sensor_name == "filter_percentage"
|
||||
and len(sensor_values[sensor_name]) == 3
|
||||
and not filter_percentage_done.done()
|
||||
):
|
||||
filter_percentage_done.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
@@ -80,6 +88,7 @@ async def test_sensor_filters_delta(
|
||||
"filter_max": "Filter Max",
|
||||
"filter_baseline_max": "Filter Baseline Max",
|
||||
"filter_zero_delta": "Filter Zero Delta",
|
||||
"filter_percentage": "Filter Percentage",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -98,13 +107,14 @@ async def test_sensor_filters_delta(
|
||||
"Test Filter Max": "filter_max",
|
||||
"Test Filter Baseline Max": "filter_baseline_max",
|
||||
"Test Filter Zero Delta": "filter_zero_delta",
|
||||
"Test Filter Percentage": "filter_percentage",
|
||||
}
|
||||
buttons = {}
|
||||
for entity in entities:
|
||||
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
|
||||
buttons[button_name_map[entity.name]] = entity.key
|
||||
|
||||
assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}"
|
||||
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
|
||||
|
||||
# Test 1: Min
|
||||
sensor_values["filter_min"].clear()
|
||||
@@ -161,3 +171,18 @@ async def test_sensor_filters_delta(
|
||||
assert sensor_values["filter_zero_delta"] == pytest.approx(expected), (
|
||||
f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}"
|
||||
)
|
||||
|
||||
# Test 5: Percentage (delta: 50%)
|
||||
sensor_values["filter_percentage"].clear()
|
||||
client.button_command(buttons["filter_percentage"])
|
||||
try:
|
||||
await asyncio.wait_for(filter_percentage_done, timeout=2.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Test 5 timed out. Values: {sensor_values['filter_percentage']}"
|
||||
)
|
||||
|
||||
expected = [100.0, 160.0, 250.0]
|
||||
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
|
||||
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user