mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c65c55df0 | ||
|
|
7704ad9384 | ||
|
|
50fdc8e85b | ||
|
|
3e6324445b | ||
|
|
e0e5cbdc27 | ||
|
|
870b80e463 | ||
|
|
899e88be72 |
@@ -212,7 +212,6 @@ esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/filter_lifetime/* @tuct
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/friedrich/* @rwrozelle
|
||||
@@ -243,9 +242,6 @@ esphome/components/haier/button/* @paveldn
|
||||
esphome/components/haier/sensor/* @paveldn
|
||||
esphome/components/haier/switch/* @paveldn
|
||||
esphome/components/haier/text_sensor/* @paveldn
|
||||
esphome/components/haier_tundra/* @fauxpark
|
||||
esphome/components/haier_tundra/button/* @fauxpark
|
||||
esphome/components/haier_tundra/switch/* @fauxpark
|
||||
esphome/components/havells_solar/* @sourabhjaiswal
|
||||
esphome/components/hbridge/fan/* @WeekendWarrior
|
||||
esphome/components/hbridge/light/* @DotNetDann
|
||||
@@ -290,7 +286,6 @@ esphome/components/i2s_audio/microphone/* @jesserockz
|
||||
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
|
||||
esphome/components/i2s_clockless_led_strip/* @j9brown
|
||||
esphome/components/iaqcore/* @yozik04
|
||||
esphome/components/icm20689/* @lboue
|
||||
esphome/components/icnt86/* @danepowell
|
||||
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
|
||||
esphome/components/improv_base/* @esphome/core
|
||||
|
||||
+1
-16
@@ -906,21 +906,6 @@ async def while_action_to_code(
|
||||
return var
|
||||
|
||||
|
||||
def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType:
|
||||
"""Rename an inherited `iteration` so the inner repeat's own counter can shadow it."""
|
||||
names = {name for _, name in args}
|
||||
renamed: TemplateArgsType = []
|
||||
for type_, name in args:
|
||||
if name == "iteration":
|
||||
depth = 1
|
||||
while f"iteration_{depth}" in names:
|
||||
depth += 1
|
||||
name = f"iteration_{depth}"
|
||||
names.add(name)
|
||||
renamed.append((type_, name))
|
||||
return renamed
|
||||
|
||||
|
||||
@register_action(
|
||||
"repeat",
|
||||
RepeatAction,
|
||||
@@ -944,7 +929,7 @@ async def repeat_action_to_code(
|
||||
actions = await build_action_list(
|
||||
config[CONF_THEN],
|
||||
cg.TemplateArguments(cg.uint32, *template_arg.args),
|
||||
[(cg.uint32, "iteration"), *_shadow_iteration(args)],
|
||||
[(cg.uint32, "iteration"), *args],
|
||||
)
|
||||
for action in actions:
|
||||
cg.add(var.add_then_action(action))
|
||||
|
||||
@@ -33,9 +33,35 @@ _CACHE_VERSION = 1
|
||||
_LAMBDA_KEY = "__esphome_lambda__"
|
||||
|
||||
|
||||
_CACHE_SUFFIX = ".validated.json"
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
|
||||
return CORE.data_dir / "storage" / f"{config_filename}{_CACHE_SUFFIX}"
|
||||
|
||||
|
||||
def invalidate_compiled_config() -> None:
|
||||
"""Drop every cache in the storage dir after a change to a file the
|
||||
mtime check cannot see, such as secrets.yaml or an include; other
|
||||
configurations may share that file."""
|
||||
storage = compiled_config_path(CORE.config_filename).parent
|
||||
try:
|
||||
caches = [p for p in storage.iterdir() if p.name.endswith(_CACHE_SUFFIX)]
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as err:
|
||||
raise EsphomeError(
|
||||
f"Could not list the validated config caches in {storage}: {err}"
|
||||
) from err
|
||||
for path in caches:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
# A stale cache would present the old key on the next upload
|
||||
raise EsphomeError(
|
||||
f"Could not remove the validated config cache {path}: {err}"
|
||||
) from err
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
|
||||
@@ -111,36 +111,24 @@ void ES8388::setup() {
|
||||
// Power on ADC, Enable LIN&RIN, Power off MICBIAS, set int1lp to low power mode
|
||||
ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCPOWER, 0x09));
|
||||
|
||||
#ifdef USE_ES8388_SELECT
|
||||
#ifdef USE_SELECT
|
||||
if (this->dac_output_select_ != nullptr) {
|
||||
if (this->dac_output_initial_.has_value()) {
|
||||
const DacOutputLine line = this->dac_output_initial_.value();
|
||||
ES8388_ERROR_FAILED(this->set_dac_output(line));
|
||||
this->dac_output_select_->publish_state(static_cast<size_t>(line));
|
||||
} else {
|
||||
auto dac_power = this->get_dac_power();
|
||||
if (dac_power.has_value()) {
|
||||
if (this->dac_output_select_->has_index(dac_power.value())) {
|
||||
this->dac_output_select_->publish_state(dac_power.value());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value());
|
||||
}
|
||||
auto dac_power = this->get_dac_power();
|
||||
if (dac_power.has_value()) {
|
||||
if (this->dac_output_select_->has_index(dac_power.value())) {
|
||||
this->dac_output_select_->publish_state(dac_power.value());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this->adc_input_mic_select_ != nullptr) {
|
||||
if (this->adc_input_mic_initial_.has_value()) {
|
||||
const AdcInputMicLine line = this->adc_input_mic_initial_.value();
|
||||
ES8388_ERROR_FAILED(this->set_adc_input_mic(line));
|
||||
this->adc_input_mic_select_->publish_state(static_cast<size_t>(line));
|
||||
} else {
|
||||
auto mic_input = this->get_mic_input();
|
||||
if (mic_input.has_value()) {
|
||||
if (this->adc_input_mic_select_->has_index(mic_input.value())) {
|
||||
this->adc_input_mic_select_->publish_state(mic_input.value());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value());
|
||||
}
|
||||
auto mic_input = this->get_mic_input();
|
||||
if (mic_input.has_value()) {
|
||||
if (this->adc_input_mic_select_->has_index(mic_input.value())) {
|
||||
this->adc_input_mic_select_->publish_state(mic_input.value());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +138,7 @@ void ES8388::setup() {
|
||||
void ES8388::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
#ifdef USE_ES8388_SELECT
|
||||
#ifdef USE_SELECT
|
||||
LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_);
|
||||
LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_);
|
||||
#endif
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include "esphome/components/audio_dac/audio_dac.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/component.h"
|
||||
#ifdef USE_ES8388_SELECT
|
||||
#ifdef USE_SELECT
|
||||
#include "esphome/components/select/select.h"
|
||||
#endif
|
||||
|
||||
@@ -26,7 +26,7 @@ enum AdcInputMicLine : uint8_t {
|
||||
};
|
||||
|
||||
class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
|
||||
#ifdef USE_ES8388_SELECT
|
||||
#ifdef USE_SELECT
|
||||
SUB_SELECT(dac_output)
|
||||
SUB_SELECT(adc_input_mic)
|
||||
#endif
|
||||
@@ -68,22 +68,11 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I
|
||||
bool set_dac_output(DacOutputLine line);
|
||||
bool set_adc_input_mic(AdcInputMicLine line);
|
||||
|
||||
#ifdef USE_ES8388_SELECT
|
||||
/// Line to apply in setup() instead of keeping what the codec reports.
|
||||
void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; }
|
||||
void set_adc_input_mic_initial(AdcInputMicLine line) { this->adc_input_mic_initial_ = line; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// @brief Mutes or unmutes the DAC audio out
|
||||
/// @param mute_state True to mute, false to unmute
|
||||
/// @return True if successful and false otherwise
|
||||
bool set_mute_state_(bool mute_state);
|
||||
|
||||
#ifdef USE_ES8388_SELECT
|
||||
optional<DacOutputLine> dac_output_initial_{};
|
||||
optional<AdcInputMicLine> adc_input_mic_initial_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::es8388
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_INITIAL_OPTION,
|
||||
ENTITY_CATEGORY_CONFIG,
|
||||
ICON_CHIP, # noqa: F401
|
||||
)
|
||||
from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns
|
||||
@@ -13,21 +9,6 @@ from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns
|
||||
CONF_DAC_OUTPUT = "dac_output"
|
||||
CONF_ADC_INPUT_MIC = "adc_input_mic"
|
||||
|
||||
DacOutputLine = es8388_ns.enum("DacOutputLine")
|
||||
DAC_OUTPUT_OPTIONS = {
|
||||
"LINE1": DacOutputLine.DAC_OUTPUT_LINE1,
|
||||
"LINE2": DacOutputLine.DAC_OUTPUT_LINE2,
|
||||
"BOTH": DacOutputLine.DAC_OUTPUT_BOTH,
|
||||
}
|
||||
DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS)
|
||||
AdcInputMicLine = es8388_ns.enum("AdcInputMicLine")
|
||||
ADC_INPUT_MIC_OPTIONS = {
|
||||
"LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1,
|
||||
"LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2,
|
||||
"DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE,
|
||||
}
|
||||
ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS)
|
||||
|
||||
DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select)
|
||||
ADCInputMicSelect = es8388_ns.class_("ADCInputMicSelect", select.Select)
|
||||
|
||||
@@ -38,45 +19,24 @@ CONFIG_SCHEMA = cv.All(
|
||||
DacOutputSelect,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon=ICON_CHIP,
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(CONF_INITIAL_OPTION): cv.enum(
|
||||
DAC_OUTPUT_OPTIONS, upper=True
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ADC_INPUT_MIC): select.select_schema(
|
||||
ADCInputMicSelect,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
icon=ICON_CHIP,
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(CONF_INITIAL_OPTION): cv.enum(
|
||||
ADC_INPUT_MIC_OPTIONS, upper=True
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_ES8388_SELECT")
|
||||
hub = await cg.get_variable(config[CONF_ES8388_ID])
|
||||
selects = select.sub_selects(config, parent=hub)
|
||||
await selects(
|
||||
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=DAC_OUTPUT_OPTION_NAMES
|
||||
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"]
|
||||
)
|
||||
await selects(
|
||||
CONF_ADC_INPUT_MIC,
|
||||
hub.set_adc_input_mic_select,
|
||||
options=ADC_INPUT_MIC_OPTION_NAMES,
|
||||
options=["LINE1", "LINE2", "DIFFERENCE"],
|
||||
)
|
||||
if (
|
||||
initial := config.get(CONF_DAC_OUTPUT, {}).get(CONF_INITIAL_OPTION)
|
||||
) is not None:
|
||||
cg.add(hub.set_dac_output_initial(initial))
|
||||
if (
|
||||
initial := config.get(CONF_ADC_INPUT_MIC, {}).get(CONF_INITIAL_OPTION)
|
||||
) is not None:
|
||||
cg.add(hub.set_adc_input_mic_initial(initial))
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
CODEOWNERS = ["@tuct"]
|
||||
DOMAIN = "filter_lifetime"
|
||||
@@ -1,112 +0,0 @@
|
||||
#include "filter_lifetime.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::filter_lifetime {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "filter_lifetime");
|
||||
|
||||
static constexpr uint32_t SECONDS_PER_HOUR = 3600;
|
||||
static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f;
|
||||
|
||||
void FilterLifetime::setup() {
|
||||
this->pref_ = this->make_entity_preference<uint32_t>();
|
||||
this->pref_.load(&this->runtime_seconds_);
|
||||
this->last_update_ = millis();
|
||||
this->publish_values_();
|
||||
}
|
||||
|
||||
void FilterLifetime::update() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
const uint32_t elapsed_ms = now - this->last_update_;
|
||||
this->last_update_ = now;
|
||||
|
||||
if (this->get_is_on_()) {
|
||||
float speed = this->get_current_speed_();
|
||||
if (std::isnan(speed))
|
||||
speed = 0.0f;
|
||||
speed = std::clamp(speed, 0.0f, 100.0f);
|
||||
const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR;
|
||||
this->carry_ms_ += static_cast<uint32_t>(elapsed_ms * (speed / 100.0f));
|
||||
this->runtime_seconds_ += this->carry_ms_ / 1000;
|
||||
this->carry_ms_ %= 1000;
|
||||
// Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266.
|
||||
if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours)
|
||||
this->pref_.save(&this->runtime_seconds_);
|
||||
ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_);
|
||||
}
|
||||
this->publish_values_();
|
||||
}
|
||||
|
||||
void FilterLifetime::publish_values_() {
|
||||
const float runtime_minutes = this->runtime_seconds_ / 60.0f;
|
||||
const float max_minutes = this->max_lifetime_minutes_;
|
||||
const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes);
|
||||
this->publish_state(100.0f * remaining_minutes / max_minutes);
|
||||
if (this->runtime_hours_sensor_ != nullptr)
|
||||
this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f);
|
||||
if (this->remaining_days_sensor_ != nullptr)
|
||||
this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY);
|
||||
}
|
||||
|
||||
void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); }
|
||||
|
||||
void FilterLifetime::reset_filter() {
|
||||
this->runtime_seconds_ = 0;
|
||||
this->carry_ms_ = 0;
|
||||
this->pref_.save(&this->runtime_seconds_);
|
||||
this->publish_values_();
|
||||
ESP_LOGI(TAG, "Filter lifetime reset");
|
||||
}
|
||||
|
||||
bool FilterLifetime::get_is_on_() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
// Reads false until the sensor first publishes, so nothing accumulates before then.
|
||||
if (this->is_on_sensor_ != nullptr)
|
||||
return this->is_on_sensor_->state;
|
||||
#endif
|
||||
if (this->is_on_lambda_ != nullptr)
|
||||
return this->is_on_lambda_();
|
||||
return true;
|
||||
}
|
||||
|
||||
float FilterLifetime::get_current_speed_() {
|
||||
if (this->current_speed_sensor_ != nullptr)
|
||||
return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0
|
||||
if (this->current_speed_lambda_ != nullptr)
|
||||
return this->current_speed_lambda_();
|
||||
return 100.0f;
|
||||
}
|
||||
|
||||
void FilterLifetime::dump_config() {
|
||||
LOG_SENSOR("", "Filter Lifetime", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Max lifetime: %" PRIu32 " min (%.1f days)\n"
|
||||
" Runtime: %.1f h",
|
||||
this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY,
|
||||
this->runtime_seconds_ / 3600.0f);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->is_on_sensor_ != nullptr) {
|
||||
LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
ESP_LOGCONFIG(TAG, " Is-on: %s",
|
||||
this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always"));
|
||||
}
|
||||
if (this->current_speed_sensor_ != nullptr) {
|
||||
LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Speed: %s",
|
||||
this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%"));
|
||||
}
|
||||
LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_);
|
||||
LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
} // namespace esphome::filter_lifetime
|
||||
@@ -1,50 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::filter_lifetime {
|
||||
|
||||
class FilterLifetime : public sensor::Sensor, public PollingComponent {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
void on_shutdown() override;
|
||||
void reset_filter();
|
||||
|
||||
void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; }
|
||||
void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; }
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; }
|
||||
#endif
|
||||
void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; }
|
||||
void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; }
|
||||
void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; }
|
||||
void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; }
|
||||
|
||||
protected:
|
||||
bool get_is_on_();
|
||||
float get_current_speed_();
|
||||
void publish_values_();
|
||||
|
||||
bool (*is_on_lambda_)(){nullptr};
|
||||
float (*current_speed_lambda_)(){nullptr};
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
binary_sensor::BinarySensor *is_on_sensor_{nullptr};
|
||||
#endif
|
||||
sensor::Sensor *current_speed_sensor_{nullptr};
|
||||
sensor::Sensor *runtime_hours_sensor_{nullptr};
|
||||
sensor::Sensor *remaining_days_sensor_{nullptr};
|
||||
ESPPreferenceObject pref_;
|
||||
uint32_t max_lifetime_minutes_{0};
|
||||
uint32_t last_update_{0};
|
||||
uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted
|
||||
uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second
|
||||
};
|
||||
|
||||
} // namespace esphome::filter_lifetime
|
||||
@@ -1,94 +0,0 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
DEVICE_CLASS_DURATION,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_HOUR,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_MAX_LIFETIME = "max_lifetime"
|
||||
CONF_IS_ON = "is_on"
|
||||
CONF_IS_ON_SENSOR = "is_on_sensor"
|
||||
CONF_CURRENT_SPEED = "current_speed"
|
||||
CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor"
|
||||
CONF_RUNTIME_HOURS = "runtime_hours"
|
||||
CONF_REMAINING_DAYS = "remaining_days"
|
||||
UNIT_DAY = "d"
|
||||
|
||||
filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime")
|
||||
FilterLifetime = filter_lifetime_ns.class_(
|
||||
"FilterLifetime", sensor.Sensor, cg.PollingComponent
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(
|
||||
FilterLifetime,
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=2,
|
||||
icon="mdi:air-filter",
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Required(CONF_MAX_LIFETIME): cv.All(
|
||||
cv.positive_time_period_minutes,
|
||||
cv.Range(min=cv.TimePeriod(minutes=1)),
|
||||
),
|
||||
cv.Optional(CONF_IS_ON): cv.returning_lambda,
|
||||
cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor),
|
||||
cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda,
|
||||
cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor),
|
||||
cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_HOUR,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
icon="mdi:timer-outline",
|
||||
),
|
||||
cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_DAY,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
icon="mdi:calendar-clock",
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s")),
|
||||
cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR),
|
||||
cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME]))
|
||||
|
||||
if (is_on := config.get(CONF_IS_ON)) is not None:
|
||||
cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool)))
|
||||
elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None:
|
||||
cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor)))
|
||||
|
||||
if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None:
|
||||
cg.add(
|
||||
var.set_current_speed_lambda(await cg.templatable(current_speed, [], float))
|
||||
)
|
||||
elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None:
|
||||
cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor)))
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor)
|
||||
await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"filter_lifetime.reset_filter",
|
||||
cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}),
|
||||
automation.ApplyCall("reset_filter()"),
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
DOMAIN = "haier_tundra"
|
||||
CODEOWNERS = ["@fauxpark"]
|
||||
@@ -1,43 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_LIGHT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..climate import HaierTundra, haier_tundra_ns
|
||||
|
||||
CODEOWNERS = ["@fauxpark"]
|
||||
|
||||
HaierTundraButton = haier_tundra_ns.class_(
|
||||
"HaierTundraButton", button.Button, cg.Component
|
||||
)
|
||||
ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True)
|
||||
|
||||
ICON_LED_OUTLINE = "mdi:led-outline"
|
||||
ICON_SPRAY_BOTTLE = "mdi:spray-bottle"
|
||||
|
||||
CONF_HAIER_TUNDRA_ID = "haier_tundra_id"
|
||||
CONF_SELF_CLEAN = "self_clean"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra),
|
||||
cv.Optional(CONF_LIGHT): button.button_schema(
|
||||
HaierTundraButton, icon=ICON_LED_OUTLINE
|
||||
),
|
||||
cv.Optional(CONF_SELF_CLEAN): button.button_schema(
|
||||
HaierTundraButton, icon=ICON_SPRAY_BOTTLE
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID])
|
||||
for key, kind in (
|
||||
(CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT),
|
||||
(CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN),
|
||||
):
|
||||
if (conf := config.get(key)) is not None:
|
||||
var = await button.new_button(conf, hub, kind)
|
||||
await cg.register_component(var, conf)
|
||||
@@ -1,18 +0,0 @@
|
||||
#include "haier_tundra_button.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::haier_tundra {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "haier_tundra.button");
|
||||
|
||||
void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); }
|
||||
|
||||
void HaierTundraButton::press_action() {
|
||||
if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) {
|
||||
this->parent_->toggle_light();
|
||||
} else {
|
||||
this->parent_->start_self_clean();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::haier_tundra
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "esphome/components/haier_tundra/haier_tundra.h"
|
||||
|
||||
namespace esphome::haier_tundra {
|
||||
|
||||
enum class ButtonKind : uint8_t {
|
||||
BUTTON_KIND_LIGHT,
|
||||
BUTTON_KIND_SELF_CLEAN,
|
||||
};
|
||||
|
||||
class HaierTundraButton final : public button::Button, public Component, public Parented<HaierTundra> {
|
||||
public:
|
||||
HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {}
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void press_action() override;
|
||||
|
||||
ButtonKind kind_;
|
||||
};
|
||||
|
||||
} // namespace esphome::haier_tundra
|
||||
@@ -1,51 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PLATFORM
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
|
||||
haier_tundra_ns = cg.esphome_ns.namespace("haier_tundra")
|
||||
HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR)
|
||||
|
||||
CONF_UNIT = "unit"
|
||||
Unit = haier_tundra_ns.enum("Unit")
|
||||
UNITS = {
|
||||
"a": Unit.HAIER_HEADER_UNIT_A,
|
||||
"b": Unit.HAIER_HEADER_UNIT_B,
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend(
|
||||
{
|
||||
cv.Optional(CONF_UNIT, default="a"): cv.enum(UNITS, lower=True),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _final_validate_unit(config: ConfigType) -> None:
|
||||
# Each transmitter may drive at most one unit A and one unit B
|
||||
climates = fv.full_config.get()["climate"]
|
||||
seen = set()
|
||||
|
||||
for climate in climates:
|
||||
if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra":
|
||||
unit_tx_pair = (
|
||||
climate[CONF_UNIT],
|
||||
climate[remote_base.CONF_TRANSMITTER_ID],
|
||||
)
|
||||
|
||||
if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]:
|
||||
raise cv.Invalid(
|
||||
"Only one of each unit is allowed per transmitter", path=[CONF_UNIT]
|
||||
)
|
||||
seen.add(unit_tx_pair)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate_unit
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
remote_base.request_protocol("haier")
|
||||
await climate_ir.new_climate_ir(config, config[CONF_UNIT])
|
||||
@@ -1,260 +0,0 @@
|
||||
#include "haier_tundra.h"
|
||||
|
||||
namespace esphome::haier_tundra {
|
||||
|
||||
// Number of bytes in decoded packet sans checksum
|
||||
static constexpr uint8_t HAIER_TUNDRA_FRAME_SIZE = 13;
|
||||
|
||||
void HaierTundra::transmit_state() {
|
||||
// Button value does not seem to matter too much except
|
||||
// when toggling light, as it has no bit flag of its own
|
||||
// So in the default case we can simply set it to POWER
|
||||
this->transmit_(HAIER_BUTTON_POWER);
|
||||
}
|
||||
|
||||
void HaierTundra::transmit_(uint8_t button) {
|
||||
// Force Quiet and Turbo off if we're not in Cool or Heat mode
|
||||
if (this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT) {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->quiet_mode_switch_ != nullptr) {
|
||||
this->quiet_mode_switch_->publish_state(false);
|
||||
}
|
||||
if (this->turbo_mode_switch_ != nullptr) {
|
||||
this->turbo_mode_switch_->publish_state(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
this->quiet_mode_ = false;
|
||||
this->turbo_mode_ = false;
|
||||
}
|
||||
|
||||
const uint8_t frame[HAIER_TUNDRA_FRAME_SIZE] = {
|
||||
this->unit_,
|
||||
static_cast<uint8_t>(this->temperature_() << 4 | this->vertical_swing_()),
|
||||
static_cast<uint8_t>(this->horizontal_swing_() << 5),
|
||||
static_cast<uint8_t>(this->health_() << 1),
|
||||
static_cast<uint8_t>(this->power_() << 6),
|
||||
static_cast<uint8_t>(this->fan_speed_() << 5),
|
||||
static_cast<uint8_t>(this->quiet_() << 7 | this->turbo_() << 6),
|
||||
static_cast<uint8_t>(this->operation_mode_() << 5),
|
||||
0x00,
|
||||
0x00,
|
||||
static_cast<uint8_t>((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4),
|
||||
0x00,
|
||||
button,
|
||||
};
|
||||
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
remote_base::HaierProtocol().encode(transmit.get_data(), frame, sizeof(frame));
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
uint8_t HaierTundra::temperature_() {
|
||||
return (uint8_t) roundf(clamp<float>(this->target_temperature, HAIER_TEMP_MIN, HAIER_TEMP_MAX)) - HAIER_TEMP_MIN;
|
||||
}
|
||||
|
||||
uint8_t HaierTundra::vertical_swing_() {
|
||||
if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
return HAIER_SWING_V_OFF;
|
||||
}
|
||||
switch (this->swing_mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
return HAIER_SWING_V_AUTO;
|
||||
default:
|
||||
if (this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
return HAIER_SWING_V_1BOT;
|
||||
}
|
||||
return HAIER_SWING_V_1TOP; // Cool/Dry/Auto
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t HaierTundra::horizontal_swing_() {
|
||||
switch (this->swing_mode) {
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
return HAIER_SWING_H_AUTO;
|
||||
default:
|
||||
return HAIER_SWING_H_MIDDLE;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t HaierTundra::health_() { return this->health_mode_ ? 1 : 0; }
|
||||
|
||||
uint8_t HaierTundra::power_() { return this->mode == climate::CLIMATE_MODE_OFF ? 0 : 1; }
|
||||
|
||||
uint8_t HaierTundra::fan_speed_() {
|
||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
|
||||
case climate::CLIMATE_FAN_LOW:
|
||||
return HAIER_FAN_LOW;
|
||||
case climate::CLIMATE_FAN_MEDIUM:
|
||||
return HAIER_FAN_MED;
|
||||
case climate::CLIMATE_FAN_HIGH:
|
||||
return HAIER_FAN_HIGH;
|
||||
case climate::CLIMATE_FAN_AUTO:
|
||||
default:
|
||||
return HAIER_FAN_AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t HaierTundra::quiet_() { return this->quiet_mode_ ? 1 : 0; }
|
||||
|
||||
uint8_t HaierTundra::turbo_() { return this->turbo_mode_ ? 1 : 0; }
|
||||
|
||||
uint8_t HaierTundra::operation_mode_() {
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
return HAIER_MODE_COOL;
|
||||
case climate::CLIMATE_MODE_DRY:
|
||||
return HAIER_MODE_DRY;
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
return HAIER_MODE_HEAT;
|
||||
case climate::CLIMATE_MODE_FAN_ONLY:
|
||||
return HAIER_MODE_FAN;
|
||||
case climate::CLIMATE_MODE_HEAT_COOL:
|
||||
default:
|
||||
return HAIER_MODE_AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
bool HaierTundra::on_receive(remote_base::RemoteReceiveData data) {
|
||||
auto haier = remote_base::HaierProtocol().decode(data);
|
||||
if (haier.has_value()) {
|
||||
return this->on_haier_(*haier);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool HaierTundra::on_haier_(const remote_base::HaierData &haier) {
|
||||
// Verify frame length and header
|
||||
if (haier.data.size() != HAIER_TUNDRA_FRAME_SIZE || haier.data[0] != this->unit_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this->target_temperature = this->get_temperature_(haier.data[1] >> 4);
|
||||
this->swing_mode = this->get_swing_mode_(haier.data[1] & 0xF, haier.data[2] >> 5);
|
||||
this->health_mode_ = haier.data[3] & 0x2;
|
||||
this->mode = this->get_operation_mode_(haier.data[4] & 0x40, haier.data[7] >> 5);
|
||||
this->fan_mode = this->get_fan_speed_(haier.data[5] >> 5);
|
||||
this->quiet_mode_ = haier.data[6] & 0x80;
|
||||
this->turbo_mode_ = haier.data[6] & 0x40;
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
if (this->health_mode_switch_ != nullptr) {
|
||||
this->health_mode_switch_->publish_state(this->health_mode_);
|
||||
}
|
||||
if (this->quiet_mode_switch_ != nullptr) {
|
||||
this->quiet_mode_switch_->publish_state(this->quiet_mode_);
|
||||
}
|
||||
if (this->turbo_mode_switch_ != nullptr) {
|
||||
this->turbo_mode_switch_->publish_state(this->turbo_mode_);
|
||||
}
|
||||
#endif
|
||||
|
||||
this->publish_state();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
float HaierTundra::get_temperature_(uint8_t temp) { return temp + HAIER_TEMP_MIN; }
|
||||
|
||||
climate::ClimateSwingMode HaierTundra::get_swing_mode_(uint8_t swing_v, uint8_t swing_h) {
|
||||
if (swing_v == HAIER_SWING_V_AUTO) {
|
||||
if (swing_h == HAIER_SWING_H_AUTO) {
|
||||
return climate::CLIMATE_SWING_BOTH;
|
||||
}
|
||||
|
||||
return climate::CLIMATE_SWING_VERTICAL;
|
||||
} else if (swing_h == HAIER_SWING_H_AUTO) {
|
||||
return climate::CLIMATE_SWING_HORIZONTAL;
|
||||
}
|
||||
|
||||
return climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
|
||||
climate::ClimateMode HaierTundra::get_operation_mode_(uint8_t power, uint8_t mode) {
|
||||
if (!power) {
|
||||
return climate::CLIMATE_MODE_OFF;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case HAIER_MODE_COOL:
|
||||
return climate::CLIMATE_MODE_COOL;
|
||||
case HAIER_MODE_DRY:
|
||||
return climate::CLIMATE_MODE_DRY;
|
||||
case HAIER_MODE_HEAT:
|
||||
return climate::CLIMATE_MODE_HEAT;
|
||||
case HAIER_MODE_FAN:
|
||||
return climate::CLIMATE_MODE_FAN_ONLY;
|
||||
case HAIER_MODE_AUTO:
|
||||
default:
|
||||
return climate::CLIMATE_MODE_HEAT_COOL;
|
||||
}
|
||||
}
|
||||
|
||||
climate::ClimateFanMode HaierTundra::get_fan_speed_(uint8_t fan) {
|
||||
switch (fan) {
|
||||
case HAIER_FAN_LOW:
|
||||
return climate::CLIMATE_FAN_LOW;
|
||||
case HAIER_FAN_MED:
|
||||
return climate::CLIMATE_FAN_MEDIUM;
|
||||
case HAIER_FAN_HIGH:
|
||||
return climate::CLIMATE_FAN_HIGH;
|
||||
case HAIER_FAN_AUTO:
|
||||
default:
|
||||
return climate::CLIMATE_FAN_AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
void HaierTundra::set_health_mode(bool health, bool transmit) {
|
||||
this->health_mode_ = health;
|
||||
|
||||
if (transmit && this->mode != climate::CLIMATE_MODE_OFF) {
|
||||
this->transmit_(HAIER_BUTTON_HEALTH);
|
||||
}
|
||||
}
|
||||
|
||||
void HaierTundra::set_quiet_mode(bool quiet, bool transmit) {
|
||||
// Quiet & Turbo cannot be enabled at the same time
|
||||
if (quiet) {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->turbo_mode_switch_ != nullptr) {
|
||||
this->turbo_mode_switch_->publish_state(false);
|
||||
}
|
||||
#endif
|
||||
this->turbo_mode_ = false;
|
||||
}
|
||||
|
||||
this->quiet_mode_ = quiet;
|
||||
if (transmit)
|
||||
this->transmit_(HAIER_BUTTON_TURBO_QUIET);
|
||||
}
|
||||
|
||||
void HaierTundra::set_turbo_mode(bool turbo, bool transmit) {
|
||||
// Quiet & Turbo cannot be enabled at the same time
|
||||
if (turbo) {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->quiet_mode_switch_ != nullptr) {
|
||||
this->quiet_mode_switch_->publish_state(false);
|
||||
}
|
||||
#endif
|
||||
this->quiet_mode_ = false;
|
||||
}
|
||||
|
||||
this->turbo_mode_ = turbo;
|
||||
if (transmit)
|
||||
this->transmit_(HAIER_BUTTON_TURBO_QUIET);
|
||||
}
|
||||
|
||||
void HaierTundra::toggle_light() {
|
||||
if (this->mode != climate::CLIMATE_MODE_OFF) {
|
||||
this->transmit_(HAIER_BUTTON_LIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
void HaierTundra::start_self_clean() {
|
||||
// Can be started when off
|
||||
this->transmit_(HAIER_BUTTON_SELF_CLEAN);
|
||||
}
|
||||
|
||||
} // namespace esphome::haier_tundra
|
||||
@@ -1,139 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
#include "esphome/components/remote_base/haier_protocol.h"
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::haier_tundra {
|
||||
|
||||
// Byte 0 - Header value (Unit ID)
|
||||
enum Unit : uint8_t {
|
||||
HAIER_HEADER_UNIT_B = 0x59,
|
||||
HAIER_HEADER_UNIT_A = 0xA6,
|
||||
};
|
||||
|
||||
// Byte 1[7:4] - Temperature (˚C) 0x0..0xE
|
||||
static constexpr uint8_t HAIER_TEMP_MIN = 16;
|
||||
static constexpr uint8_t HAIER_TEMP_MAX = 30;
|
||||
|
||||
// Byte 1[3:0] - Vertical Swing
|
||||
static constexpr uint8_t HAIER_SWING_V_OFF = 0x0; // Power off value; from Auto, stops at current position
|
||||
static constexpr uint8_t HAIER_SWING_V_2TOP = 0x1; // [|/ ]
|
||||
static constexpr uint8_t HAIER_SWING_V_1TOP = 0x2; // [| ] (Not available in Heat mode)
|
||||
static constexpr uint8_t HAIER_SWING_V_2BOT = 0x3; // [ //]
|
||||
static constexpr uint8_t HAIER_SWING_V_1BOT = 0xA; // [ /] (Heat mode only)
|
||||
static constexpr uint8_t HAIER_SWING_V_AUTO = 0xC;
|
||||
|
||||
// Byte 2[7:5] - Horizontal Swing
|
||||
static constexpr uint8_t HAIER_SWING_H_MIDDLE = 0x0; // [ || ]
|
||||
static constexpr uint8_t HAIER_SWING_H_LEFT = 0x3; // [/ ]
|
||||
static constexpr uint8_t HAIER_SWING_H_MIDDLE_LEFT = 0x4; // [ | ]
|
||||
static constexpr uint8_t HAIER_SWING_H_MIDDLE_RIGHT = 0x5; // [ | ]
|
||||
static constexpr uint8_t HAIER_SWING_H_RIGHT = 0x6; // [ \]
|
||||
static constexpr uint8_t HAIER_SWING_H_AUTO = 0x7; // [/||\]
|
||||
|
||||
// Byte 3[7:5] - Timer Mode
|
||||
static constexpr uint8_t HAIER_TIMER_MODE_DISABLED = 0x0;
|
||||
static constexpr uint8_t HAIER_TIMER_MODE_OFF = 0x1; // OFF
|
||||
static constexpr uint8_t HAIER_TIMER_MODE_ON = 0x2; // ON
|
||||
static constexpr uint8_t HAIER_TIMER_MODE_ON_OFF = 0x4; // ON → OFF
|
||||
static constexpr uint8_t HAIER_TIMER_MODE_OFF_ON = 0x5; // ON ← OFF
|
||||
|
||||
// Byte 3[1] - Health
|
||||
// Byte 4[6] - Power
|
||||
|
||||
// Byte 5[7:5] - Fan Speed
|
||||
static constexpr uint8_t HAIER_FAN_HIGH = 0x1;
|
||||
static constexpr uint8_t HAIER_FAN_MED = 0x2;
|
||||
static constexpr uint8_t HAIER_FAN_LOW = 0x3;
|
||||
static constexpr uint8_t HAIER_FAN_AUTO = 0x5;
|
||||
|
||||
// Byte 5[4:0] - Timer Off Hours (0-23) 0x00..0x17
|
||||
// Byte 6[7] - Quiet Mode
|
||||
// Byte 6[6] - Turbo Mode
|
||||
// Byte 6[5:0] - Timer Off Minutes (0-59) 0x00..0x3B
|
||||
|
||||
// Byte 7[7:5] - Mode
|
||||
static constexpr uint8_t HAIER_MODE_AUTO = 0x0;
|
||||
static constexpr uint8_t HAIER_MODE_COOL = 0x1;
|
||||
static constexpr uint8_t HAIER_MODE_DRY = 0x2;
|
||||
static constexpr uint8_t HAIER_MODE_HEAT = 0x4;
|
||||
static constexpr uint8_t HAIER_MODE_FAN = 0x6;
|
||||
|
||||
// Byte 7[4:0] - Timer On Hours (0-23) 0x00..0x17
|
||||
// Byte 8[7] - Sleep Mode
|
||||
// Byte 8[5:0] - Timer On Minutes (0-59) 0x00..0x3B
|
||||
// Byte 10[4] - Self Clean
|
||||
|
||||
// Byte 12 - Button
|
||||
static constexpr uint8_t HAIER_BUTTON_TEMP_UP = 0x00;
|
||||
static constexpr uint8_t HAIER_BUTTON_TEMP_DOWN = 0x01;
|
||||
static constexpr uint8_t HAIER_BUTTON_SWING_V = 0x02;
|
||||
static constexpr uint8_t HAIER_BUTTON_SWING_H = 0x03;
|
||||
static constexpr uint8_t HAIER_BUTTON_FAN_SPEED = 0x04;
|
||||
static constexpr uint8_t HAIER_BUTTON_POWER = 0x05;
|
||||
static constexpr uint8_t HAIER_BUTTON_MODE = 0x06;
|
||||
static constexpr uint8_t HAIER_BUTTON_HEALTH = 0x07;
|
||||
static constexpr uint8_t HAIER_BUTTON_TURBO_QUIET = 0x08;
|
||||
static constexpr uint8_t HAIER_BUTTON_SLEEP = 0x0B;
|
||||
static constexpr uint8_t HAIER_BUTTON_TIMER = 0x10;
|
||||
static constexpr uint8_t HAIER_BUTTON_LIGHT = 0x15;
|
||||
static constexpr uint8_t HAIER_BUTTON_SELF_CLEAN = 0x19;
|
||||
|
||||
class HaierTundra final : public climate_ir::ClimateIR {
|
||||
public:
|
||||
explicit HaierTundra(Unit unit)
|
||||
: climate_ir::ClimateIR(HAIER_TEMP_MIN, HAIER_TEMP_MAX, 1.0f, true, true,
|
||||
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
|
||||
climate::CLIMATE_FAN_HIGH},
|
||||
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}),
|
||||
unit_(unit) {}
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
SUB_SWITCH(health_mode);
|
||||
SUB_SWITCH(quiet_mode);
|
||||
SUB_SWITCH(turbo_mode);
|
||||
#endif
|
||||
|
||||
void set_health_mode(bool health, bool transmit = true);
|
||||
void set_quiet_mode(bool quiet, bool transmit = true);
|
||||
void set_turbo_mode(bool turbo, bool transmit = true);
|
||||
|
||||
void toggle_light();
|
||||
void start_self_clean();
|
||||
|
||||
protected:
|
||||
// Transmit via IR the state of this climate controller.
|
||||
void transmit_state() override;
|
||||
|
||||
void transmit_(uint8_t button);
|
||||
uint8_t temperature_();
|
||||
uint8_t vertical_swing_();
|
||||
uint8_t horizontal_swing_();
|
||||
uint8_t health_();
|
||||
uint8_t power_();
|
||||
uint8_t fan_speed_();
|
||||
uint8_t quiet_();
|
||||
uint8_t turbo_();
|
||||
uint8_t operation_mode_();
|
||||
|
||||
// Handle received IR Buffer
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
bool on_haier_(const remote_base::HaierData &haier);
|
||||
|
||||
float get_temperature_(uint8_t temp);
|
||||
climate::ClimateSwingMode get_swing_mode_(uint8_t swing_v, uint8_t swing_h);
|
||||
climate::ClimateMode get_operation_mode_(uint8_t power, uint8_t mode);
|
||||
climate::ClimateFanMode get_fan_speed_(uint8_t fan);
|
||||
|
||||
Unit unit_;
|
||||
bool health_mode_{false};
|
||||
bool quiet_mode_{false};
|
||||
bool turbo_mode_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::haier_tundra
|
||||
@@ -1,60 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from ..climate import HaierTundra, haier_tundra_ns
|
||||
|
||||
CODEOWNERS = ["@fauxpark"]
|
||||
|
||||
HaierTundraSwitch = haier_tundra_ns.class_(
|
||||
"HaierTundraSwitch", switch.Switch, cg.Component
|
||||
)
|
||||
SwitchKind = haier_tundra_ns.enum("SwitchKind", is_class=True)
|
||||
|
||||
ICON_LEAF = "mdi:leaf"
|
||||
ICON_ARROW_DOWN_RIGHT = "mdi:arrow-down-right"
|
||||
ICON_ARROW_UP_RIGHT_BOLD = "mdi:arrow-up-right-bold"
|
||||
|
||||
CONF_HAIER_TUNDRA_ID = "haier_tundra_id"
|
||||
CONF_HEALTH_MODE = "health_mode"
|
||||
CONF_QUIET_MODE = "quiet_mode"
|
||||
CONF_TURBO_MODE = "turbo_mode"
|
||||
|
||||
|
||||
def _switch_schema(icon: str) -> cv.Schema:
|
||||
return switch.switch_schema(
|
||||
HaierTundraSwitch,
|
||||
icon=icon,
|
||||
default_restore_mode="RESTORE_DEFAULT_OFF",
|
||||
device_class=DEVICE_CLASS_SWITCH,
|
||||
entity_category=ENTITY_CATEGORY_CONFIG,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra),
|
||||
cv.Optional(CONF_HEALTH_MODE): _switch_schema(ICON_LEAF),
|
||||
cv.Optional(CONF_QUIET_MODE): _switch_schema(ICON_ARROW_DOWN_RIGHT),
|
||||
cv.Optional(CONF_TURBO_MODE): _switch_schema(ICON_ARROW_UP_RIGHT_BOLD),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID])
|
||||
switches = switch.sub_switches(config)
|
||||
if sw := await switches(
|
||||
CONF_HEALTH_MODE, hub.set_health_mode_switch, hub, SwitchKind.SWITCH_KIND_HEALTH
|
||||
):
|
||||
await cg.register_component(sw, config[CONF_HEALTH_MODE])
|
||||
if sw := await switches(
|
||||
CONF_QUIET_MODE, hub.set_quiet_mode_switch, hub, SwitchKind.SWITCH_KIND_QUIET
|
||||
):
|
||||
await cg.register_component(sw, config[CONF_QUIET_MODE])
|
||||
if sw := await switches(
|
||||
CONF_TURBO_MODE, hub.set_turbo_mode_switch, hub, SwitchKind.SWITCH_KIND_TURBO
|
||||
):
|
||||
await cg.register_component(sw, config[CONF_TURBO_MODE])
|
||||
@@ -1,43 +0,0 @@
|
||||
#include "haier_tundra_switch.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::haier_tundra {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "haier_tundra.switch");
|
||||
|
||||
void HaierTundraSwitch::setup() {
|
||||
auto initial = this->get_initial_state_with_restore_mode();
|
||||
if (initial.has_value()) {
|
||||
const bool state = initial.value();
|
||||
this->apply_(state, false);
|
||||
this->publish_state(state);
|
||||
}
|
||||
}
|
||||
|
||||
void HaierTundraSwitch::dump_config() { LOG_SWITCH("", "Haier Tundra Switch", this); }
|
||||
|
||||
void HaierTundraSwitch::write_state(bool state) {
|
||||
// Quiet and Turbo can only be turned on in Cool or Heat mode
|
||||
if (state && this->kind_ != SwitchKind::SWITCH_KIND_HEALTH && this->parent_->mode != climate::CLIMATE_MODE_COOL &&
|
||||
this->parent_->mode != climate::CLIMATE_MODE_HEAT) {
|
||||
return;
|
||||
}
|
||||
this->apply_(state, true);
|
||||
this->publish_state(state);
|
||||
}
|
||||
|
||||
void HaierTundraSwitch::apply_(bool state, bool transmit) {
|
||||
switch (this->kind_) {
|
||||
case SwitchKind::SWITCH_KIND_HEALTH:
|
||||
this->parent_->set_health_mode(state, transmit);
|
||||
break;
|
||||
case SwitchKind::SWITCH_KIND_QUIET:
|
||||
this->parent_->set_quiet_mode(state, transmit);
|
||||
break;
|
||||
case SwitchKind::SWITCH_KIND_TURBO:
|
||||
this->parent_->set_turbo_mode(state, transmit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::haier_tundra
|
||||
@@ -1,28 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#include "esphome/components/haier_tundra/haier_tundra.h"
|
||||
|
||||
namespace esphome::haier_tundra {
|
||||
|
||||
enum class SwitchKind : uint8_t {
|
||||
SWITCH_KIND_HEALTH,
|
||||
SWITCH_KIND_QUIET,
|
||||
SWITCH_KIND_TURBO,
|
||||
};
|
||||
|
||||
class HaierTundraSwitch final : public switch_::Switch, public Component, public Parented<HaierTundra> {
|
||||
public:
|
||||
HaierTundraSwitch(HaierTundra *parent, SwitchKind kind) : Parented(parent), kind_(kind) {}
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override;
|
||||
void apply_(bool state, bool transmit);
|
||||
|
||||
SwitchKind kind_;
|
||||
};
|
||||
|
||||
} // namespace esphome::haier_tundra
|
||||
@@ -1,13 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import spi
|
||||
from esphome.components.motion import MotionComponent
|
||||
|
||||
CODEOWNERS = ["@lboue"]
|
||||
DOMAIN = "icm20689"
|
||||
|
||||
CONF_ICM20689_ID = "icm20689_id"
|
||||
# C++ namespace / class
|
||||
icm20689_ns = cg.esphome_ns.namespace("icm20689")
|
||||
ICM20689Component = icm20689_ns.class_(
|
||||
"ICM20689Component", MotionComponent, spi.SPIDevice
|
||||
)
|
||||
@@ -1,126 +0,0 @@
|
||||
#include "icm20689.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::icm20689 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "icm20689");
|
||||
|
||||
namespace {
|
||||
|
||||
// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range.
|
||||
constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; }
|
||||
|
||||
constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; }
|
||||
|
||||
} // namespace
|
||||
|
||||
void ICM20689Component::write_register_(uint8_t reg, uint8_t value) {
|
||||
this->enable();
|
||||
this->write_byte(reg & 0x7F);
|
||||
this->write_byte(value);
|
||||
this->disable();
|
||||
}
|
||||
|
||||
void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) {
|
||||
this->enable();
|
||||
this->write_byte(reg | ICM20689_READ_BIT);
|
||||
this->read_array(data, len);
|
||||
this->disable();
|
||||
}
|
||||
|
||||
void ICM20689Component::setup() {
|
||||
MotionComponent::setup();
|
||||
|
||||
this->spi_setup();
|
||||
|
||||
if (!this->spi_is_ready()) {
|
||||
ESP_LOGE(TAG, "SPI device is not ready");
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
// Datasheet 4.20: a soft reset must be the first operation after power-up,
|
||||
// before any register initialization. WHO_AM_I is only valid afterwards.
|
||||
this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET);
|
||||
delay(100); // NOLINT
|
||||
|
||||
// The chip comes out of reset in sleep mode: wake it and select the
|
||||
// auto-selected best clock source (required for full gyro performance).
|
||||
this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO);
|
||||
|
||||
// USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake.
|
||||
// Datasheet 6.1: disable the I2C interface when using SPI.
|
||||
this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS);
|
||||
|
||||
uint8_t who_am_i = 0;
|
||||
this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1);
|
||||
|
||||
if (who_am_i != ICM20689_WHO_AM_I_VALUE) {
|
||||
ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE);
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow the sensor to settle after waking from sleep.
|
||||
delay(50); // NOLINT
|
||||
|
||||
// Configure the accelerometer and gyroscope full-scale ranges.
|
||||
this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast<uint8_t>(this->accel_range_) << 3);
|
||||
this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast<uint8_t>(this->gyro_range_) << 3);
|
||||
}
|
||||
|
||||
void ICM20689Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "ICM20689 IMU:");
|
||||
LOG_PIN(" CS Pin: ", this->cs_);
|
||||
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast<uint8_t>(this->accel_range_));
|
||||
ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast<uint8_t>(this->gyro_range_));
|
||||
|
||||
MotionComponent::dump_config();
|
||||
}
|
||||
|
||||
bool ICM20689Component::update_data(motion::MotionData &data) {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
|
||||
const uint8_t accel_range = static_cast<uint8_t>(this->accel_range_);
|
||||
const uint8_t gyro_range = static_cast<uint8_t>(this->gyro_range_);
|
||||
|
||||
// Read one contiguous sample:
|
||||
// 0..5 accelerometer X/Y/Z
|
||||
// 6..7 temperature
|
||||
// 8..13 gyroscope X/Y/Z
|
||||
uint8_t raw_data[14] = {};
|
||||
this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data));
|
||||
|
||||
const float accel_factor = accel_scale(accel_range);
|
||||
|
||||
data.acceleration[motion::X_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[0], raw_data[1])) * accel_factor;
|
||||
data.acceleration[motion::Y_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[2], raw_data[3])) * accel_factor;
|
||||
data.acceleration[motion::Z_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[4], raw_data[5])) * accel_factor;
|
||||
|
||||
const float gyro_factor = gyro_scale(gyro_range);
|
||||
|
||||
data.angular_rate[motion::X_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor;
|
||||
data.angular_rate[motion::Y_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor;
|
||||
data.angular_rate[motion::Z_AXIS] = static_cast<int16_t>(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor;
|
||||
|
||||
if (!this->temperature_callback_.empty()) {
|
||||
// Datasheet formula: temperature (°C) = raw / 326.8 + 25.
|
||||
const float temperature = static_cast<int16_t>(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f;
|
||||
this->temperature_callback_.call(temperature);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::icm20689
|
||||
@@ -1,66 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/motion/motion_component.h"
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::icm20689 {
|
||||
|
||||
// Register map
|
||||
static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75;
|
||||
static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B;
|
||||
static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A;
|
||||
static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B;
|
||||
static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C;
|
||||
static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B;
|
||||
static constexpr uint8_t ICM20689_READ_BIT = 0x80;
|
||||
static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98;
|
||||
static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20)
|
||||
static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock)
|
||||
static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4
|
||||
|
||||
// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG
|
||||
enum ICM20689AccelRange : uint8_t {
|
||||
ICM20689_ACCEL_RANGE_2G = 0x00,
|
||||
ICM20689_ACCEL_RANGE_4G = 0x01,
|
||||
ICM20689_ACCEL_RANGE_8G = 0x02,
|
||||
ICM20689_ACCEL_RANGE_16G = 0x03,
|
||||
};
|
||||
|
||||
// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG
|
||||
enum ICM20689GyroRange : uint8_t {
|
||||
ICM20689_GYRO_RANGE_250 = 0x00,
|
||||
ICM20689_GYRO_RANGE_500 = 0x01,
|
||||
ICM20689_GYRO_RANGE_1000 = 0x02,
|
||||
ICM20689_GYRO_RANGE_2000 = 0x03,
|
||||
};
|
||||
|
||||
class ICM20689Component final : public motion::MotionComponent,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
|
||||
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; }
|
||||
void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; }
|
||||
|
||||
template<typename F> void add_temperature_listener(F &&callback) {
|
||||
this->temperature_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
bool update_data(motion::MotionData &data) override;
|
||||
|
||||
void write_register_(uint8_t reg, uint8_t value);
|
||||
// Reads len bytes starting at reg in one CS-low transaction.
|
||||
void read_registers_(uint8_t reg, uint8_t *data, size_t len);
|
||||
|
||||
ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G};
|
||||
ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250};
|
||||
|
||||
LazyCallbackManager<void(float)> temperature_callback_{};
|
||||
};
|
||||
|
||||
} // namespace esphome::icm20689
|
||||
@@ -1,56 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import spi
|
||||
from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE
|
||||
from esphome.components.motion import motion_schema, new_motion_component
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import ICM20689Component, icm20689_ns
|
||||
|
||||
DEPENDENCIES = ["spi"]
|
||||
|
||||
# Enum proxies (must match the C++ enum values exactly)
|
||||
ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange")
|
||||
ACCEL_RANGE_OPTIONS = {
|
||||
"2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G,
|
||||
"4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G,
|
||||
"8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G,
|
||||
"16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G,
|
||||
}
|
||||
|
||||
ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange")
|
||||
GYRO_RANGE_OPTIONS = {
|
||||
"250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250,
|
||||
"500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500,
|
||||
"1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000,
|
||||
"2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000,
|
||||
}
|
||||
|
||||
# Top-level CONFIG_SCHEMA
|
||||
CONFIG_SCHEMA = (
|
||||
motion_schema(ICM20689Component, has_accel=True, has_gyro=True)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum(
|
||||
ACCEL_RANGE_OPTIONS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum(
|
||||
GYRO_RANGE_OPTIONS, upper=True
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(
|
||||
spi.spi_device_schema(
|
||||
cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Code generation
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await new_motion_component(config)
|
||||
await spi.register_spi_device(var, config)
|
||||
|
||||
cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE]))
|
||||
cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE]))
|
||||
@@ -1,42 +0,0 @@
|
||||
# YAML config keys
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TYPE,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
ICON_THERMOMETER,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_ICM20689_ID, ICM20689Component
|
||||
|
||||
AUTO_LOAD = ["icm20689"]
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
icon=ICON_THERMOMETER,
|
||||
accuracy_decimals=2,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
).extend(
|
||||
{
|
||||
cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
|
||||
cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await sensor.new_sensor(config)
|
||||
parent = await cg.get_variable(config[CONF_ICM20689_ID])
|
||||
data = MockObj("data")
|
||||
value_lambda = await cg.process_lambda(
|
||||
var.publish_state(data),
|
||||
[(cg.float_, str(data))],
|
||||
)
|
||||
cg.add(parent.add_temperature_listener(value_lambda))
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "ld2420.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
/*
|
||||
Configure commands - little endian
|
||||
@@ -103,13 +102,16 @@ static constexpr uint8_t LD2420_ERROR_UNKNOWN = 0x01;
|
||||
static constexpr uint16_t CMD_MIN_GATE_REG = 0x0000;
|
||||
static constexpr uint16_t CMD_MAX_GATE_REG = 0x0001;
|
||||
static constexpr uint16_t CMD_TIMEOUT_REG = 0x0004;
|
||||
// Gate threshold registers are the base plus the gate number
|
||||
static constexpr uint16_t CMD_GATE_MOVE_THRESH_BASE = 0x0010;
|
||||
static constexpr uint16_t CMD_GATE_STILL_THRESH_BASE = 0x0020;
|
||||
static constexpr uint16_t FACTORY_MOVE_THRESH[TOTAL_GATES] PROGMEM = {60000, 30000, 400, 250, 250, 250, 250, 250,
|
||||
250, 250, 250, 250, 250, 250, 250, 250};
|
||||
static constexpr uint16_t FACTORY_STILL_THRESH[TOTAL_GATES] PROGMEM = {40000, 20000, 200, 200, 200, 200, 200, 150,
|
||||
150, 100, 100, 100, 100, 100, 100, 100};
|
||||
static constexpr uint16_t CMD_GATE_MOVE_THRESH[TOTAL_GATES] = {0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015,
|
||||
0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B,
|
||||
0x001C, 0x001D, 0x001E, 0x001F};
|
||||
static constexpr uint16_t CMD_GATE_STILL_THRESH[TOTAL_GATES] = {0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025,
|
||||
0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B,
|
||||
0x002C, 0x002D, 0x002E, 0x002F};
|
||||
static constexpr uint32_t FACTORY_MOVE_THRESH[TOTAL_GATES] = {60000, 30000, 400, 250, 250, 250, 250, 250,
|
||||
250, 250, 250, 250, 250, 250, 250, 250};
|
||||
static constexpr uint32_t FACTORY_STILL_THRESH[TOTAL_GATES] = {40000, 20000, 200, 200, 200, 200, 200, 150,
|
||||
150, 100, 100, 100, 100, 100, 100, 100};
|
||||
static constexpr uint16_t FACTORY_TIMEOUT = 120;
|
||||
static constexpr uint16_t FACTORY_MIN_GATE = 1;
|
||||
static constexpr uint16_t FACTORY_MAX_GATE = 12;
|
||||
@@ -143,8 +145,11 @@ static constexpr StringToUint8 OP_MODE_BY_STR[] = {
|
||||
{"Simple", OP_SIMPLE_MODE},
|
||||
};
|
||||
|
||||
// Only passed to %s log arguments, which read flash directly on ESP8266
|
||||
PROGMEM_STRING_TABLE(ErrMessageStrings, "None", "Unknown", "Timeout");
|
||||
static constexpr const char *ERR_MESSAGE[] = {
|
||||
"None",
|
||||
"Unknown",
|
||||
"Timeout",
|
||||
};
|
||||
|
||||
// Helper function for lookups
|
||||
template<size_t N> uint8_t find_uint8(const StringToUint8 (&arr)[N], const std::string &str) {
|
||||
@@ -298,8 +303,8 @@ void LD2420Component::factory_reset_action() {
|
||||
this->max_gate_distance_number_->state = FACTORY_MAX_GATE;
|
||||
#endif
|
||||
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
|
||||
this->new_config.move_thresh[gate] = progmem_read_uint16(&FACTORY_MOVE_THRESH[gate]);
|
||||
this->new_config.still_thresh[gate] = progmem_read_uint16(&FACTORY_STILL_THRESH[gate]);
|
||||
this->new_config.move_thresh[gate] = FACTORY_MOVE_THRESH[gate];
|
||||
this->new_config.still_thresh[gate] = FACTORY_STILL_THRESH[gate];
|
||||
delay_microseconds_safe(125);
|
||||
this->set_gate_threshold(gate);
|
||||
}
|
||||
@@ -741,10 +746,10 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) {
|
||||
}
|
||||
|
||||
void LD2420Component::handle_cmd_error(uint16_t error) {
|
||||
if (error < ErrMessageStrings::COUNT) {
|
||||
ESP_LOGE(TAG, "Command failed: %s", LOG_STR_ARG(ErrMessageStrings::get_log_str(error, 0)));
|
||||
if (error < std::size(ERR_MESSAGE)) {
|
||||
ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]);
|
||||
} else {
|
||||
// The error word comes from the device reply frame; unknown codes are logged as numbers
|
||||
// The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE
|
||||
ESP_LOGE(TAG, "Command failed: error 0x%04X", error);
|
||||
}
|
||||
}
|
||||
@@ -755,11 +760,9 @@ int LD2420Component::get_gate_threshold_(uint8_t gate) {
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
cmd_frame.command = CMD_READ_ABD_PARAM;
|
||||
const uint16_t move_threshold_gate = CMD_GATE_MOVE_THRESH_BASE + gate;
|
||||
const uint16_t still_threshold_gate = CMD_GATE_STILL_THRESH_BASE + gate;
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &move_threshold_gate, sizeof(move_threshold_gate));
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_MOVE_THRESH[gate], sizeof(CMD_GATE_MOVE_THRESH[gate]));
|
||||
cmd_frame.data_length += 2;
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &still_threshold_gate, sizeof(still_threshold_gate));
|
||||
memcpy(&cmd_frame.data[cmd_frame.data_length], &CMD_GATE_STILL_THRESH[gate], sizeof(CMD_GATE_STILL_THRESH[gate]));
|
||||
cmd_frame.data_length += 2;
|
||||
cmd_frame.footer = CMD_FRAME_FOOTER;
|
||||
ESP_LOGV(TAG, "Sending read gate %d high/low threshold command: %2X", gate, cmd_frame.command);
|
||||
@@ -865,8 +868,8 @@ void LD2420Component::set_gate_threshold(uint8_t gate) {
|
||||
// HH HH HH HH LL LL CC CC RR RR VV VV VV VV RR RR VV VV VV VV FF FF FF FF
|
||||
// FD FC FB FA 14 00 07 00 10 00 00 FF 00 00 00 01 00 0F 00 00 04 03 02 01
|
||||
|
||||
uint16_t move_threshold_gate = CMD_GATE_MOVE_THRESH_BASE + gate;
|
||||
uint16_t still_threshold_gate = CMD_GATE_STILL_THRESH_BASE + gate;
|
||||
uint16_t move_threshold_gate = CMD_GATE_MOVE_THRESH[gate];
|
||||
uint16_t still_threshold_gate = CMD_GATE_STILL_THRESH[gate];
|
||||
CmdFrameT cmd_frame;
|
||||
cmd_frame.data_length = 0;
|
||||
cmd_frame.header = CMD_FRAME_HEADER;
|
||||
|
||||
@@ -85,7 +85,7 @@ class LvColorPickerType : public LvCompound {
|
||||
if (left != nullptr) {
|
||||
lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN);
|
||||
// A narrow hue column may be slimmer than its label.
|
||||
lv_obj_set_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true);
|
||||
lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
|
||||
}
|
||||
|
||||
// The text rows keep their natural height and the bars between them take what is left,
|
||||
@@ -233,7 +233,7 @@ class LvColorPickerType : public LvCompound {
|
||||
static lv_obj_t *create_box(lv_obj_t *parent) {
|
||||
auto *obj = lv_obj_create(parent);
|
||||
lv_obj_add_style(obj, &plain_box, LV_PART_MAIN);
|
||||
lv_obj_set_flag(obj, LV_OBJ_FLAG_SCROLLABLE, false);
|
||||
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ class LvColorPickerType : public LvCompound {
|
||||
lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN);
|
||||
lv_obj_set_flag(disc, LV_OBJ_FLAG_SCROLLABLE, false);
|
||||
lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE);
|
||||
return disc;
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ class LvColorPickerType : public LvCompound {
|
||||
this->wheel_ = create_disc(container, &wheel_hue_grad);
|
||||
// Not clickable, so touches fall through to the wheel underneath.
|
||||
auto *overlay = create_disc(this->wheel_, &wheel_white_grad);
|
||||
lv_obj_set_flag(overlay, LV_OBJ_FLAG_CLICKABLE, false);
|
||||
lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
this->wheel_handle_ = lv_obj_create(this->wheel_);
|
||||
lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE);
|
||||
@@ -345,8 +345,8 @@ class LvColorPickerType : public LvCompound {
|
||||
lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN);
|
||||
lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE, false);
|
||||
lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE, false);
|
||||
lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// PRESSED as well, so a tap takes effect without the finger moving.
|
||||
lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this);
|
||||
@@ -359,7 +359,7 @@ class LvColorPickerType : public LvCompound {
|
||||
|
||||
void add_value_label_(SliderIndex index, lv_color_t color) {
|
||||
auto *slider = this->sliders_[index];
|
||||
lv_obj_set_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true);
|
||||
lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
|
||||
auto *label = lv_label_create(slider);
|
||||
this->values_[index] = label;
|
||||
lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN);
|
||||
@@ -509,7 +509,7 @@ class LvColorPickerType : public LvCompound {
|
||||
static void bubble_events(lv_obj_t *obj) {
|
||||
for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) {
|
||||
auto *child = lv_obj_get_child(obj, i);
|
||||
lv_obj_set_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE, true);
|
||||
lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE);
|
||||
bubble_events(child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
@@ -45,12 +44,9 @@ from esphome.core import (
|
||||
coroutine_with_priority,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build
|
||||
|
||||
from .const import (
|
||||
CONF_CORE_OPTIONS,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_EXT_PAN_ID,
|
||||
CONF_FORCE_DATASET,
|
||||
@@ -63,7 +59,6 @@ from .const import (
|
||||
CONF_PSKC,
|
||||
CONF_SRP_ID,
|
||||
CONF_TLV,
|
||||
OPENTHREAD_CONFIG_PREFIX,
|
||||
)
|
||||
|
||||
CODEOWNERS = ["@mrene"]
|
||||
@@ -89,10 +84,6 @@ CONF_DEVICE_TYPES = [
|
||||
"MTD",
|
||||
]
|
||||
|
||||
OT_HEADER_DIR = "openthread"
|
||||
OT_HEADER_FILE = "esphome_ot_config.h"
|
||||
OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+")
|
||||
|
||||
|
||||
def _validate_txpower(value: Any) -> int | float:
|
||||
if CORE.is_esp32:
|
||||
@@ -167,31 +158,6 @@ def set_sdkconfig_options(config: ConfigType) -> None:
|
||||
add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True)
|
||||
|
||||
|
||||
def _write_custom_header(config: ConfigType) -> None:
|
||||
"""Write core_options to the OpenThread custom header."""
|
||||
if not (options := config.get(CONF_CORE_OPTIONS)):
|
||||
return
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True)
|
||||
# IDF resolves the path relative to the project (build) directory.
|
||||
add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE
|
||||
)
|
||||
content = (
|
||||
"// Auto-generated by ESPHome from openthread core_options - do not edit\n"
|
||||
"#pragma once\n\n"
|
||||
+ "".join(
|
||||
f"#define {name} {value}\n" for name, value in sorted(options.items())
|
||||
)
|
||||
)
|
||||
# PlatformIO does not track this header as an OpenThread input; the esp-idf
|
||||
# toolchain does.
|
||||
header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE)
|
||||
if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf:
|
||||
clean_build(clear_pio_cache=False)
|
||||
|
||||
|
||||
openthread_ns = cg.esphome_ns.namespace("openthread")
|
||||
OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component)
|
||||
OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component)
|
||||
@@ -254,34 +220,6 @@ def _validate_platform(config: ConfigType) -> ConfigType:
|
||||
)(config)
|
||||
|
||||
|
||||
def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]:
|
||||
for name in options:
|
||||
if (
|
||||
not name.startswith(OPENTHREAD_CONFIG_PREFIX)
|
||||
or name == OPENTHREAD_CONFIG_PREFIX
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{name}' is not an OpenThread core option; names must be "
|
||||
f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name",
|
||||
path=[name],
|
||||
)
|
||||
if not OT_CORE_OPTION_NAME.fullmatch(name):
|
||||
raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name])
|
||||
return options
|
||||
|
||||
|
||||
def _validate_core_option_value(value: Any) -> str:
|
||||
value = cv.string_strict(value)
|
||||
if not value.strip():
|
||||
raise cv.Invalid("OpenThread core option value must not be empty")
|
||||
if "\n" in value or "\r" in value:
|
||||
raise cv.Invalid("OpenThread core option value must be a single line")
|
||||
# GCC treats a backslash followed by spaces as a line continuation too.
|
||||
if value.rstrip().endswith("\\"):
|
||||
raise cv.Invalid("OpenThread core option value must not end with \\")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_tlv_hex(value: Any) -> str:
|
||||
s = cv.string_strict(value)
|
||||
if len(s) % 2 != 0:
|
||||
@@ -314,11 +252,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
_validate_txpower,
|
||||
),
|
||||
cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_CORE_OPTIONS): cv.All(
|
||||
cv.only_on_esp32,
|
||||
{cv.string_strict: _validate_core_option_value},
|
||||
_validate_core_option_names,
|
||||
),
|
||||
}
|
||||
).extend(_CONNECTION_SCHEMA),
|
||||
cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV),
|
||||
@@ -392,7 +325,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if CORE.is_esp32:
|
||||
set_sdkconfig_options(config)
|
||||
_write_custom_header(config)
|
||||
elif CORE.using_zephyr:
|
||||
# Crypto through PSA: mbedtls, plus Oberon from SDK 2.7
|
||||
include_west_project("mbedtls")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
CONF_CORE_OPTIONS = "core_options"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
CONF_EXT_PAN_ID = "ext_pan_id"
|
||||
CONF_FORCE_DATASET = "force_dataset"
|
||||
@@ -11,5 +10,3 @@ CONF_POLL_PERIOD = "poll_period"
|
||||
CONF_PSKC = "pskc"
|
||||
CONF_SRP_ID = "srp_id"
|
||||
CONF_TLV = "tlv"
|
||||
|
||||
OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_"
|
||||
|
||||
@@ -10,7 +10,6 @@ from esphome.const import (
|
||||
CONF_MODE,
|
||||
CONF_NUMBER,
|
||||
CONF_OUTPUT,
|
||||
CONF_RESET,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
@@ -27,14 +26,11 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin)
|
||||
|
||||
CONF_PCF8574 = "pcf8574"
|
||||
CONF_PCF8575 = "pcf8575"
|
||||
# Keep in sync with PCF8574Component::reset_ in pcf8574.h.
|
||||
DEFAULT_RESET = True
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
|
||||
cv.Optional(CONF_PCF8575, default=False): cv.boolean,
|
||||
cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean,
|
||||
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
|
||||
}
|
||||
)
|
||||
@@ -48,8 +44,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
cg.add(var.set_pcf8575(config[CONF_PCF8575]))
|
||||
if (reset := config[CONF_RESET]) != DEFAULT_RESET:
|
||||
cg.add(var.set_reset(reset))
|
||||
if interrupt_pin := config.get(CONF_INTERRUPT_PIN):
|
||||
cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin)))
|
||||
|
||||
|
||||
@@ -12,13 +12,8 @@ void PCF8574Component::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->reset_) {
|
||||
this->write_gpio_();
|
||||
this->read_gpio_();
|
||||
} else {
|
||||
this->output_mask_ = this->input_mask_;
|
||||
this->mode_mask_ = ~this->configured_input_pins_;
|
||||
}
|
||||
this->write_gpio_();
|
||||
this->read_gpio_();
|
||||
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->setup();
|
||||
@@ -45,9 +40,8 @@ void PCF8574Component::loop() {
|
||||
void PCF8574Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"PCF8574:\n"
|
||||
" Is PCF8575: %s\n"
|
||||
" Reset: %s",
|
||||
YESNO(this->pcf8575_), YESNO(this->reset_));
|
||||
" Is PCF8575: %s",
|
||||
YESNO(this->pcf8575_));
|
||||
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
|
||||
LOG_I2C_DEVICE(this)
|
||||
if (this->is_failed()) {
|
||||
@@ -88,12 +82,20 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) {
|
||||
bool PCF8574Component::read_gpio_() {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read.
|
||||
if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) {
|
||||
bool success;
|
||||
uint8_t data[2];
|
||||
if (this->pcf8575_) {
|
||||
success = this->read_bytes_raw(data, 2);
|
||||
this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0);
|
||||
} else {
|
||||
success = this->read_bytes_raw(data, 1);
|
||||
this->input_mask_ = data[0];
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
this->status_set_warning();
|
||||
return false;
|
||||
}
|
||||
this->input_mask_ = (uint16_t(data[1]) << 8) | data[0];
|
||||
this->status_clear_warning();
|
||||
return true;
|
||||
}
|
||||
@@ -121,11 +123,6 @@ bool PCF8574Component::write_gpio_() {
|
||||
float PCF8574Component::get_setup_priority() const { return setup_priority::IO; }
|
||||
|
||||
void PCF8574GPIOPin::setup() { pin_mode(flags_); }
|
||||
void PCF8574GPIOPin::set_flags(gpio::Flags flags) {
|
||||
this->flags_ = flags;
|
||||
if (flags & gpio::FLAG_INPUT)
|
||||
this->parent_->register_input_pin(this->pin_);
|
||||
}
|
||||
void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); }
|
||||
bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; }
|
||||
void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); }
|
||||
|
||||
@@ -16,9 +16,6 @@ class PCF8574Component final : public Component,
|
||||
PCF8574Component() = default;
|
||||
|
||||
void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; }
|
||||
void set_reset(bool reset) { this->reset_ = reset; }
|
||||
// Called by pin setters before any component's setup or GPIO writes.
|
||||
void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; }
|
||||
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
|
||||
|
||||
/// Check i2c availability and setup masks
|
||||
@@ -47,11 +44,6 @@ class PCF8574Component final : public Component,
|
||||
uint16_t output_mask_{0x00};
|
||||
/// The state read in read_gpio_ - 1 means HIGH, 0 means LOW
|
||||
uint16_t input_mask_{0x00};
|
||||
// Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins
|
||||
// (including undeclared pins) are treated as outputs seeded from their observed levels.
|
||||
uint16_t configured_input_pins_{0x00};
|
||||
// Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default.
|
||||
bool reset_{true};
|
||||
bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574
|
||||
InternalGPIOPin *interrupt_pin_{nullptr};
|
||||
};
|
||||
@@ -71,8 +63,7 @@ class PCF8574GPIOPin final : public GPIOPin {
|
||||
void set_parent(PCF8574Component *parent) { parent_ = parent; }
|
||||
void set_pin(uint8_t pin) { pin_ = pin; }
|
||||
void set_inverted(bool inverted) { inverted_ = inverted; }
|
||||
// Call after set_parent() and set_pin(): input flags register the pin with the parent.
|
||||
void set_flags(gpio::Flags flags);
|
||||
void set_flags(gpio::Flags flags) { flags_ = flags; }
|
||||
|
||||
gpio::Flags get_flags() const override { return this->flags_; }
|
||||
|
||||
|
||||
@@ -295,11 +295,8 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
|
||||
}
|
||||
|
||||
void loop() override {
|
||||
if (this->num_running_ == 0) {
|
||||
// Nothing left to wait for, e.g. once the last queued item has run
|
||||
this->disable_loop();
|
||||
if (this->num_running_ == 0)
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->script_->is_running())
|
||||
return;
|
||||
@@ -308,10 +305,9 @@ template<class C, typename... Ts> class ScriptWaitAction final : public Action<T
|
||||
// Processing all items in a while loop causes infinite loops because
|
||||
// play_next_() can trigger more items to be queued
|
||||
if (!this->param_queue_.empty()) {
|
||||
// Take the item out first: the chain may call stop(), which clears the queue
|
||||
auto params = std::move(this->param_queue_.front());
|
||||
this->param_queue_.pop_front();
|
||||
auto ¶ms = this->param_queue_.front();
|
||||
std::apply([this](auto &...args) { this->play_next_(args...); }, params);
|
||||
this->param_queue_.pop_front();
|
||||
} else {
|
||||
// Queue is now empty - disable loop until next play_complex
|
||||
this->disable_loop();
|
||||
|
||||
@@ -1,54 +1,10 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import tmc22xx
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_THRESHOLD
|
||||
|
||||
CODEOWNERS = ["@remcom"]
|
||||
DOMAIN = "tmc2209"
|
||||
|
||||
CONF_SEDN = "sedn"
|
||||
CONF_SEIMIN = "seimin"
|
||||
CONF_SEMAX = "semax"
|
||||
CONF_SEMIN = "semin"
|
||||
CONF_SEUP = "seup"
|
||||
CONF_TCOOL_THRESHOLD = "tcool_threshold"
|
||||
|
||||
tmc2209_ns = cg.esphome_ns.namespace("tmc2209")
|
||||
TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper)
|
||||
|
||||
tmc22xx.register_actions("tmc2209", TMC2209Stepper)
|
||||
|
||||
automation.register_apply_action(
|
||||
"tmc2209.stallguard",
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TMC2209Stepper),
|
||||
cv.Optional(CONF_THRESHOLD): cv.templatable(cv.uint8_t),
|
||||
cv.Optional(CONF_TCOOL_THRESHOLD): cv.templatable(
|
||||
cv.int_range(0, 2**20 - 1)
|
||||
),
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_THRESHOLD, "set_stallguard_threshold", cg.uint8),
|
||||
automation.ApplyField(CONF_TCOOL_THRESHOLD, "set_tcool_threshold", cg.uint32),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"tmc2209.coolconf",
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(TMC2209Stepper),
|
||||
cv.Optional(CONF_SEMIN): cv.templatable(cv.int_range(0, 15)),
|
||||
cv.Optional(CONF_SEMAX): cv.templatable(cv.int_range(0, 15)),
|
||||
cv.Optional(CONF_SEUP): cv.templatable(cv.int_range(0, 3)),
|
||||
cv.Optional(CONF_SEDN): cv.templatable(cv.int_range(0, 3)),
|
||||
cv.Optional(CONF_SEIMIN): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_SEMIN, "set_semin", cg.uint8),
|
||||
automation.ApplyField(CONF_SEMAX, "set_semax", cg.uint8),
|
||||
automation.ApplyField(CONF_SEUP, "set_seup", cg.uint8),
|
||||
automation.ApplyField(CONF_SEDN, "set_sedn", cg.uint8),
|
||||
automation.ApplyField(CONF_SEIMIN, "set_seimin", cg.bool_),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from esphome import automation
|
||||
from esphome.components import tmc22xx
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import TMC2209Stepper
|
||||
@@ -8,31 +6,9 @@ from . import TMC2209Stepper
|
||||
AUTO_LOAD = ["tmc22xx"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
CONF_ON_STALL = "on_stall"
|
||||
|
||||
|
||||
def _validate_stall_needs_diag(config: ConfigType) -> ConfigType:
|
||||
if CONF_ON_STALL in config and tmc22xx.CONF_DIAG_PIN not in config:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ON_STALL}' requires '{tmc22xx.CONF_DIAG_PIN}', stalls are only reported on DIAG"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
tmc22xx.tmc22xx_schema(
|
||||
TMC2209Stepper,
|
||||
max_address=3,
|
||||
extra={cv.Optional(CONF_ON_STALL): automation.validate_automation({})},
|
||||
),
|
||||
_validate_stall_needs_diag,
|
||||
)
|
||||
CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3)
|
||||
FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await tmc22xx.new_tmc22xx(config)
|
||||
for conf in config.get(CONF_ON_STALL, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_stall_callback", [], conf
|
||||
)
|
||||
await tmc22xx.new_tmc22xx(config)
|
||||
|
||||
@@ -10,24 +10,4 @@ void TMC2209Stepper::dump_config() {
|
||||
TMC22XXStepper::dump_config();
|
||||
}
|
||||
|
||||
uint32_t *TMC2209Stepper::shadow_register(uint8_t reg) {
|
||||
switch (reg) {
|
||||
case REG_TCOOLTHRS:
|
||||
return &this->tcoolthrs_;
|
||||
case REG_SGTHRS:
|
||||
return &this->sgthrs_;
|
||||
case REG_COOLCONF:
|
||||
return &this->coolconf_;
|
||||
default:
|
||||
return TMC22XXStepper::shadow_register(reg);
|
||||
}
|
||||
}
|
||||
|
||||
void TMC2209Stepper::restore_registers() {
|
||||
TMC22XXStepper::restore_registers();
|
||||
this->write_register(REG_TCOOLTHRS, this->tcoolthrs_);
|
||||
this->write_register(REG_SGTHRS, this->sgthrs_);
|
||||
this->write_register(REG_COOLCONF, this->coolconf_);
|
||||
}
|
||||
|
||||
} // namespace esphome::tmc2209
|
||||
|
||||
@@ -4,51 +4,12 @@
|
||||
|
||||
namespace esphome::tmc2209 {
|
||||
|
||||
using tmc22xx::RegisterField;
|
||||
|
||||
// StallGuard and CoolStep registers, which the TMC2208 does not have
|
||||
static constexpr uint8_t REG_TCOOLTHRS = 0x14;
|
||||
static constexpr uint8_t REG_SGTHRS = 0x40;
|
||||
static constexpr uint8_t REG_COOLCONF = 0x42;
|
||||
|
||||
static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false};
|
||||
static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false};
|
||||
static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false};
|
||||
static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false};
|
||||
static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false};
|
||||
static constexpr RegisterField SEDN{REG_COOLCONF, 13, 2, false};
|
||||
static constexpr RegisterField SEIMIN{REG_COOLCONF, 15, 1, false};
|
||||
|
||||
class TMC2209Stepper : public tmc22xx::TMC22XXStepper {
|
||||
public:
|
||||
void dump_config() override;
|
||||
|
||||
/// Called when DIAG reports a stall. StallGuard only works in StealthChop, between TCOOLTHRS and TPWMTHRS.
|
||||
template<typename F> void add_on_stall_callback(F &&callback) {
|
||||
this->stall_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// A stall is reported when the StallGuard result drops below twice this value.
|
||||
void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); }
|
||||
/// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG.
|
||||
void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); }
|
||||
void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); }
|
||||
void set_semax(uint8_t semax) { this->write_field(SEMAX, semax); }
|
||||
void set_seup(uint8_t seup) { this->write_field(SEUP, seup); }
|
||||
void set_sedn(uint8_t sedn) { this->write_field(SEDN, sedn); }
|
||||
void set_seimin(bool seimin) { this->write_field(SEIMIN, seimin); }
|
||||
|
||||
protected:
|
||||
uint8_t expected_version() const override { return 0x21; }
|
||||
uint32_t *shadow_register(uint8_t reg) override;
|
||||
void restore_registers() override;
|
||||
void on_diag_without_error() override { this->stall_callback_.call(); }
|
||||
|
||||
LazyCallbackManager<void()> stall_callback_;
|
||||
// Write-only registers with their power-on values
|
||||
uint32_t tcoolthrs_{0};
|
||||
uint32_t sgthrs_{0};
|
||||
uint32_t coolconf_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::tmc2209
|
||||
|
||||
@@ -21,7 +21,6 @@ DEPENDENCIES = ["uart"]
|
||||
|
||||
CONF_ANALOG_CURRENT_SCALE = "analog_current_scale"
|
||||
CONF_CLOCK_FREQUENCY = "clock_frequency"
|
||||
CONF_DIAG_PIN = "diag_pin"
|
||||
CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle"
|
||||
CONF_ENN_PIN = "enn_pin"
|
||||
CONF_HOLD_CURRENT = "hold_current"
|
||||
@@ -32,7 +31,6 @@ CONF_INTERPOLATION = "interpolation"
|
||||
CONF_INVERSE_DIRECTION = "inverse_direction"
|
||||
CONF_IRUN = "irun"
|
||||
CONF_MICROSTEPS = "microsteps"
|
||||
CONF_ON_STATUS = "on_status"
|
||||
CONF_OTTRIM = "ottrim"
|
||||
CONF_RSENSE = "rsense"
|
||||
CONF_RUN_CURRENT = "run_current"
|
||||
@@ -46,7 +44,6 @@ TMC22XXStepper = tmc22xx_ns.class_(
|
||||
"TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice
|
||||
)
|
||||
StandstillMode = tmc22xx_ns.enum("StandstillMode")
|
||||
DriverEvent = tmc22xx_ns.enum("DriverEvent")
|
||||
|
||||
STANDSTILL_MODES = {
|
||||
"normal": StandstillMode.STANDSTILL_MODE_NORMAL,
|
||||
@@ -59,9 +56,7 @@ validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True)
|
||||
validate_current = cv.All(cv.current, cv.positive_float)
|
||||
|
||||
|
||||
def tmc22xx_schema(
|
||||
cls: MockObjClass, max_address: int, extra: dict | None = None
|
||||
) -> cv.Schema:
|
||||
def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema:
|
||||
"""Return the stepper schema for a family member; `max_address` is 0 for chips without address pins."""
|
||||
return cv.All(
|
||||
stepper.STEPPER_SCHEMA.extend(
|
||||
@@ -72,7 +67,6 @@ def tmc22xx_schema(
|
||||
cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Optional(CONF_DIAG_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All(
|
||||
cv.frequency, cv.int_range(min=1)
|
||||
),
|
||||
@@ -85,8 +79,6 @@ def tmc22xx_schema(
|
||||
cv.Optional(CONF_RUN_CURRENT): validate_current,
|
||||
cv.Optional(CONF_HOLD_CURRENT): validate_current,
|
||||
cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps,
|
||||
cv.Optional(CONF_ON_STATUS): automation.validate_automation({}),
|
||||
**(extra or {}),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -141,7 +133,6 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable:
|
||||
(CONF_STEP_PIN, var.set_step_pin),
|
||||
(CONF_DIR_PIN, var.set_dir_pin),
|
||||
(CONF_INDEX_PIN, var.set_index_pin),
|
||||
(CONF_DIAG_PIN, var.set_diag_pin),
|
||||
):
|
||||
if (pin := config.get(key)) is not None:
|
||||
cg.add(setter(await cg.gpio_pin_expression(pin)))
|
||||
@@ -154,13 +145,6 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable:
|
||||
):
|
||||
if (value := config.get(key)) is not None:
|
||||
cg.add(setter(value))
|
||||
for conf in config.get(CONF_ON_STATUS, []):
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_status_callback",
|
||||
[(DriverEvent, "event"), (cg.bool_, "active")],
|
||||
conf,
|
||||
)
|
||||
return var
|
||||
|
||||
|
||||
|
||||
@@ -23,49 +23,6 @@ static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resis
|
||||
static constexpr float APPROACH_TIME_S = 0.02f;
|
||||
// Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer
|
||||
static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16;
|
||||
static constexpr uint32_t STATUS_POLL_INTERVAL_MS = 1000;
|
||||
// While the driver does not answer, for example without motor supply, only poll every 10 s; each failed read
|
||||
// blocks the loop for the UART timeout
|
||||
static constexpr uint8_t UNREACHABLE_POLL_SKIP = 9;
|
||||
static constexpr uint32_t GSTAT_FLAGS = 0x07; // reset, drv_err, uv_cp
|
||||
static constexpr uint32_t DRV_STATUS_FLAGS = 0x0FFF;
|
||||
|
||||
static const LogString *driver_event_to_string(DriverEvent event) {
|
||||
switch (event) {
|
||||
case DRIVER_EVENT_RESET:
|
||||
return LOG_STR("Driver reset");
|
||||
case DRIVER_EVENT_DRIVER_ERROR:
|
||||
return LOG_STR("Driver error");
|
||||
case DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE:
|
||||
return LOG_STR("Charge pump undervoltage");
|
||||
case DRIVER_EVENT_OVERTEMPERATURE_PREWARNING:
|
||||
return LOG_STR("Overtemperature prewarning");
|
||||
case DRIVER_EVENT_OVERTEMPERATURE:
|
||||
return LOG_STR("Overtemperature shutdown");
|
||||
case DRIVER_EVENT_SHORT_TO_GROUND_A:
|
||||
return LOG_STR("Short to ground on phase A");
|
||||
case DRIVER_EVENT_SHORT_TO_GROUND_B:
|
||||
return LOG_STR("Short to ground on phase B");
|
||||
case DRIVER_EVENT_LOW_SIDE_SHORT_A:
|
||||
return LOG_STR("Low side short on phase A");
|
||||
case DRIVER_EVENT_LOW_SIDE_SHORT_B:
|
||||
return LOG_STR("Low side short on phase B");
|
||||
case DRIVER_EVENT_OPEN_LOAD_A:
|
||||
return LOG_STR("Open load on phase A");
|
||||
case DRIVER_EVENT_OPEN_LOAD_B:
|
||||
return LOG_STR("Open load on phase B");
|
||||
case DRIVER_EVENT_TEMPERATURE_120C:
|
||||
return LOG_STR("Temperature above 120C");
|
||||
case DRIVER_EVENT_TEMPERATURE_143C:
|
||||
return LOG_STR("Temperature above 143C");
|
||||
case DRIVER_EVENT_TEMPERATURE_150C:
|
||||
return LOG_STR("Temperature above 150C");
|
||||
case DRIVER_EVENT_TEMPERATURE_157C:
|
||||
return LOG_STR("Temperature above 157C");
|
||||
default:
|
||||
return LOG_STR("Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first
|
||||
static uint8_t datagram_crc(const uint8_t *data, size_t len) {
|
||||
@@ -77,19 +34,8 @@ static uint8_t datagram_crc(const uint8_t *data, size_t len) {
|
||||
return crc;
|
||||
}
|
||||
|
||||
// Index into RESTORED_REGISTERS, or -1 when the register is not restored after a driver reset
|
||||
static int restored_index(uint8_t reg) {
|
||||
for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) {
|
||||
if (RESTORED_REGISTERS[i] == reg)
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; }
|
||||
|
||||
void IRAM_ATTR DiagPinStore::gpio_intr(DiagPinStore *arg) { arg->triggered = true; }
|
||||
|
||||
void TMC22XXStepper::setup() {
|
||||
if (this->enn_pin_ != nullptr) {
|
||||
this->enn_pin_->setup();
|
||||
@@ -107,54 +53,13 @@ void TMC22XXStepper::setup() {
|
||||
ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_);
|
||||
}
|
||||
|
||||
this->restore_registers();
|
||||
this->write_register(REG_GSTAT, GSTAT_FLAGS);
|
||||
this->apply_config_();
|
||||
// Currents live in a write-only register, so later changes survive a driver reset through its cached value
|
||||
if (!std::isnan(this->initial_run_current_))
|
||||
this->set_run_current(this->initial_run_current_);
|
||||
if (!std::isnan(this->initial_hold_current_))
|
||||
this->set_hold_current(this->initial_hold_current_);
|
||||
|
||||
if (this->index_pin_ != nullptr) {
|
||||
this->index_pin_->setup();
|
||||
this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE);
|
||||
} else {
|
||||
this->step_pin_->setup();
|
||||
this->step_pin_->digital_write(false);
|
||||
this->dir_pin_->setup();
|
||||
this->dir_pin_->digital_write(false);
|
||||
}
|
||||
|
||||
if (this->diag_pin_ != nullptr) {
|
||||
this->diag_pin_->setup();
|
||||
this->diag_pin_->attach_interrupt(DiagPinStore::gpio_intr, &this->diag_store_, gpio::INTERRUPT_RISING_EDGE);
|
||||
}
|
||||
// Polling notices a driver reset, and conditions that clear without a DIAG edge
|
||||
this->set_interval(STATUS_POLL_INTERVAL_MS, [this]() {
|
||||
if (this->status_poll_skip_ > 0) {
|
||||
this->status_poll_skip_--;
|
||||
return;
|
||||
}
|
||||
if (!this->update_status_())
|
||||
this->status_poll_skip_ = UNREACHABLE_POLL_SKIP;
|
||||
});
|
||||
|
||||
this->set_enabled(true);
|
||||
}
|
||||
|
||||
void TMC22XXStepper::restore_registers() {
|
||||
// Bring write-only registers in line with their cached values and clear the reset flag
|
||||
this->write_register(REG_IHOLD_IRUN, this->ihold_irun_);
|
||||
this->write_register(REG_TPOWERDOWN, this->tpowerdown_);
|
||||
this->write_register(REG_TPWMTHRS, this->tpwmthrs_);
|
||||
this->write_register(REG_VACTUAL, 0);
|
||||
for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) {
|
||||
if ((this->restored_written_ & (1u << i)) != 0)
|
||||
this->write_register(RESTORED_REGISTERS[i], this->restored_values_[i]);
|
||||
}
|
||||
}
|
||||
this->write_register(REG_GSTAT, 0x07);
|
||||
|
||||
void TMC22XXStepper::apply_config_() {
|
||||
this->write_field(PDN_DISABLE, true);
|
||||
this->write_field(MSTEP_REG_SELECT, true);
|
||||
this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_));
|
||||
@@ -171,59 +76,28 @@ void TMC22XXStepper::apply_config_() {
|
||||
this->toff_ = *toff;
|
||||
|
||||
this->set_microsteps(this->initial_microsteps_);
|
||||
if (!std::isnan(this->initial_run_current_))
|
||||
this->set_run_current(this->initial_run_current_);
|
||||
if (!std::isnan(this->initial_hold_current_))
|
||||
this->set_hold_current(this->initial_hold_current_);
|
||||
|
||||
if (this->index_pin_ != nullptr) {
|
||||
// INDEX toggles on every step of the internal pulse generator that VACTUAL drives
|
||||
this->write_field(INDEX_OTPW, false);
|
||||
this->write_field(INDEX_STEP, true);
|
||||
this->index_pin_->setup();
|
||||
this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE);
|
||||
} else {
|
||||
// Step on both edges, so every step pin toggle is one step
|
||||
this->write_field(DEDGE, true);
|
||||
this->write_field(MULTISTEP_FILT, false);
|
||||
this->step_pin_->setup();
|
||||
this->step_pin_->digital_write(false);
|
||||
this->dir_pin_->setup();
|
||||
this->dir_pin_->digital_write(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool TMC22XXStepper::update_status_() {
|
||||
auto gstat = this->read_register(REG_GSTAT);
|
||||
if (!gstat.has_value())
|
||||
return false;
|
||||
uint32_t drv_status = 0;
|
||||
// DRV_STATUS only feeds on_status and the DIAG check, skip the transfer when neither is used
|
||||
if (this->diag_pin_ != nullptr || !this->status_callback_.empty()) {
|
||||
auto value = this->read_register(REG_DRV_STATUS);
|
||||
if (!value.has_value())
|
||||
return false;
|
||||
drv_status = *value;
|
||||
}
|
||||
// GSTAT flags stay set until written with 1; they are set again while the condition lasts
|
||||
if ((*gstat & GSTAT_FLAGS) != 0)
|
||||
this->write_register(REG_GSTAT, *gstat & GSTAT_FLAGS);
|
||||
|
||||
uint16_t status = (*gstat & GSTAT_FLAGS) | ((drv_status & DRV_STATUS_FLAGS) << 3);
|
||||
// Restore before reporting, so an on_status automation for the reset sees and can change the restored settings
|
||||
if ((status & (1u << DRIVER_EVENT_RESET)) != 0) {
|
||||
ESP_LOGW(TAG, "Restoring settings after driver reset");
|
||||
this->restore_registers();
|
||||
// The motor stopped with the reset, ramp up again from standstill
|
||||
this->current_speed_ = 0.0f;
|
||||
this->set_enabled(this->enabled_);
|
||||
}
|
||||
uint16_t changed = status ^ this->status_;
|
||||
this->status_ = status;
|
||||
for (uint8_t event = 0; event < DRIVER_EVENT_COUNT; event++) {
|
||||
if ((changed & (1u << event)) == 0)
|
||||
continue;
|
||||
bool active = (status & (1u << event)) != 0;
|
||||
// Open load and temperature levels are informational, the driver keeps running
|
||||
if (active && event < DRIVER_EVENT_OPEN_LOAD_A) {
|
||||
ESP_LOGW(TAG, "%s", LOG_STR_ARG(driver_event_to_string(static_cast<DriverEvent>(event))));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "%s: %s", LOG_STR_ARG(driver_event_to_string(static_cast<DriverEvent>(event))),
|
||||
active ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("cleared"));
|
||||
}
|
||||
this->status_callback_.call(static_cast<DriverEvent>(event), active);
|
||||
}
|
||||
return true;
|
||||
this->set_enabled(true);
|
||||
}
|
||||
|
||||
void TMC22XXStepper::dump_config() {
|
||||
@@ -254,7 +128,6 @@ void TMC22XXStepper::dump_config() {
|
||||
LOG_PIN(" STEP Pin: ", this->step_pin_);
|
||||
LOG_PIN(" DIR Pin: ", this->dir_pin_);
|
||||
LOG_PIN(" INDEX Pin: ", this->index_pin_);
|
||||
LOG_PIN(" DIAG Pin: ", this->diag_pin_);
|
||||
LOG_STEPPER(this);
|
||||
if (this->is_failed()) {
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
@@ -264,29 +137,12 @@ void TMC22XXStepper::dump_config() {
|
||||
void TMC22XXStepper::loop() {
|
||||
if (this->index_pin_ != nullptr)
|
||||
this->add_index_pulses_();
|
||||
if (this->diag_store_.triggered) {
|
||||
this->diag_store_.triggered = false;
|
||||
// DIAG rises on a driver error, any other rise is chip specific
|
||||
if (!this->update_status_()) {
|
||||
ESP_LOGW(TAG, "DIAG rose, but the driver status could not be read");
|
||||
} else if ((this->status_ & GSTAT_FLAGS) == 0) {
|
||||
this->on_diag_without_error();
|
||||
}
|
||||
}
|
||||
|
||||
if (this->has_reached_target()) {
|
||||
this->high_freq_.stop();
|
||||
this->moving_ = false;
|
||||
} else {
|
||||
this->high_freq_.start();
|
||||
if (!this->enabled_)
|
||||
this->set_enabled(true);
|
||||
// An unreachable driver is polled only every 10 s. Read it before the first step, so settings lost when the
|
||||
// motor supply dropped are restored before the move runs on the chip defaults.
|
||||
if (!this->moving_ && this->status_poll_skip_ > 0) {
|
||||
this->status_poll_skip_ = this->update_status_() ? 0 : UNREACHABLE_POLL_SKIP;
|
||||
}
|
||||
this->moving_ = true;
|
||||
}
|
||||
|
||||
if (this->index_pin_ != nullptr) {
|
||||
@@ -455,12 +311,8 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) {
|
||||
}
|
||||
this->status_clear_warning();
|
||||
|
||||
if (uint32_t *shadow = this->shadow_register(reg)) {
|
||||
if (uint32_t *shadow = this->shadow_register(reg))
|
||||
*shadow = value;
|
||||
} else if (int index = restored_index(reg); index >= 0) {
|
||||
this->restored_values_[index] = value;
|
||||
this->restored_written_ |= 1u << index;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -494,15 +346,7 @@ bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) {
|
||||
// Skip the UART timeout when the driver never answered during setup
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
optional<uint32_t> data;
|
||||
int index = restored_index(field.reg);
|
||||
if (index >= 0 && (this->restored_written_ & (1u << index)) != 0) {
|
||||
// Modify the last written value, not the chip: after a driver reset that the poll has not seen yet, the chip
|
||||
// holds its defaults, and those would replace the values to restore
|
||||
data = this->restored_values_[index];
|
||||
} else {
|
||||
data = this->read_register(field.reg);
|
||||
}
|
||||
auto data = this->read_register(field.reg);
|
||||
if (!data.has_value())
|
||||
return false;
|
||||
uint32_t mask = field.mask();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
#include "esphome/components/stepper/stepper.h"
|
||||
@@ -31,12 +30,8 @@ static constexpr uint8_t REG_TPOWERDOWN = 0x11;
|
||||
static constexpr uint8_t REG_TPWMTHRS = 0x13;
|
||||
static constexpr uint8_t REG_VACTUAL = 0x22;
|
||||
static constexpr uint8_t REG_CHOPCONF = 0x6C;
|
||||
static constexpr uint8_t REG_DRV_STATUS = 0x6F;
|
||||
static constexpr uint8_t REG_PWMCONF = 0x70;
|
||||
|
||||
// Readable registers that a driver reset sets back to their defaults; the last written values are restored
|
||||
static constexpr std::array<uint8_t, 4> RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF};
|
||||
|
||||
static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false};
|
||||
static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false};
|
||||
static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false};
|
||||
@@ -68,33 +63,6 @@ enum StandstillMode : uint8_t {
|
||||
STANDSTILL_MODE_COIL_SHORT_HS = 3,
|
||||
};
|
||||
|
||||
/// Driver conditions reported to on_status, in the order of their status bits: GSTAT bits 0-2, then DRV_STATUS bits
|
||||
/// 0-11.
|
||||
enum DriverEvent : uint8_t {
|
||||
DRIVER_EVENT_RESET = 0,
|
||||
DRIVER_EVENT_DRIVER_ERROR,
|
||||
DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE,
|
||||
DRIVER_EVENT_OVERTEMPERATURE_PREWARNING,
|
||||
DRIVER_EVENT_OVERTEMPERATURE,
|
||||
DRIVER_EVENT_SHORT_TO_GROUND_A,
|
||||
DRIVER_EVENT_SHORT_TO_GROUND_B,
|
||||
DRIVER_EVENT_LOW_SIDE_SHORT_A,
|
||||
DRIVER_EVENT_LOW_SIDE_SHORT_B,
|
||||
DRIVER_EVENT_OPEN_LOAD_A,
|
||||
DRIVER_EVENT_OPEN_LOAD_B,
|
||||
DRIVER_EVENT_TEMPERATURE_120C,
|
||||
DRIVER_EVENT_TEMPERATURE_143C,
|
||||
DRIVER_EVENT_TEMPERATURE_150C,
|
||||
DRIVER_EVENT_TEMPERATURE_157C,
|
||||
DRIVER_EVENT_COUNT,
|
||||
};
|
||||
|
||||
/// Set by a rising edge on DIAG, which signals a driver error or, on chips that support it, a stall.
|
||||
struct DiagPinStore {
|
||||
volatile bool triggered{false};
|
||||
static void gpio_intr(DiagPinStore *arg);
|
||||
};
|
||||
|
||||
/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL.
|
||||
struct IndexPulseStore {
|
||||
volatile int32_t pulses{0};
|
||||
@@ -116,7 +84,6 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
|
||||
void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; }
|
||||
void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; }
|
||||
void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; }
|
||||
void set_diag_pin(InternalGPIOPin *pin) { this->diag_pin_ = pin; }
|
||||
void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; }
|
||||
void set_rsense(float rsense) { this->rsense_ = rsense; }
|
||||
void set_vsense(bool vsense) { this->vsense_ = vsense; }
|
||||
@@ -130,11 +97,6 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
|
||||
void set_enabled(bool enabled);
|
||||
bool is_enabled() const { return this->enabled_; }
|
||||
|
||||
/// Called with the condition and whether it became active or cleared.
|
||||
template<typename F> void add_on_status_callback(F &&callback) {
|
||||
this->status_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void set_microsteps(uint16_t microsteps);
|
||||
uint16_t get_microsteps();
|
||||
void set_interpolation(bool enable) { this->write_field(INTPOL, enable); }
|
||||
@@ -165,15 +127,6 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
|
||||
virtual uint8_t expected_version() const = 0;
|
||||
/// Return the cached value of a write-only register, or nullptr when the register is readable.
|
||||
virtual uint32_t *shadow_register(uint8_t reg);
|
||||
/// Write the cached write-only registers and the last written configuration registers to the driver, at setup
|
||||
/// and after a driver reset.
|
||||
virtual void restore_registers();
|
||||
/// Write the settings from the configuration to the readable registers.
|
||||
void apply_config_();
|
||||
/// Called when DIAG rises without a driver error.
|
||||
virtual void on_diag_without_error() {}
|
||||
/// Read GSTAT and DRV_STATUS and report changes. Returns false when the status could not be read.
|
||||
bool update_status_();
|
||||
optional<uint8_t> current_to_scale_(float current);
|
||||
float full_scale_current_();
|
||||
int32_t get_vactual_() const { return static_cast<int32_t>(extract_field(this->vactual_reg_, VACTUAL)); }
|
||||
@@ -186,36 +139,28 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U
|
||||
GPIOPin *step_pin_{nullptr};
|
||||
GPIOPin *dir_pin_{nullptr};
|
||||
InternalGPIOPin *index_pin_{nullptr};
|
||||
InternalGPIOPin *diag_pin_{nullptr};
|
||||
LazyCallbackManager<void(DriverEvent, bool)> status_callback_;
|
||||
uint32_t clock_frequency_{12000000};
|
||||
// Write-only registers shared by all family members, with their power-on values
|
||||
uint32_t ihold_irun_{0x00071703};
|
||||
uint32_t tpowerdown_{0x14};
|
||||
uint32_t tpwmthrs_{0};
|
||||
uint32_t vactual_reg_{0};
|
||||
std::array<uint32_t, RESTORED_REGISTERS.size()> restored_values_{};
|
||||
// NaN while not configured
|
||||
float rsense_{NAN};
|
||||
float initial_run_current_{NAN};
|
||||
float initial_hold_current_{NAN};
|
||||
IndexPulseStore index_store_{};
|
||||
uint16_t initial_microsteps_{16};
|
||||
uint16_t status_{0};
|
||||
optional<bool> vsense_{};
|
||||
optional<uint8_t> ottrim_{};
|
||||
uint8_t address_{0};
|
||||
uint8_t toff_{3};
|
||||
uint8_t version_{0};
|
||||
uint8_t restored_written_{0}; // Bit per RESTORED_REGISTERS entry that has been written
|
||||
uint8_t status_poll_skip_{0};
|
||||
int8_t direction_{0};
|
||||
DiagPinStore diag_store_{};
|
||||
bool analog_current_scale_{false};
|
||||
bool enabled_{false};
|
||||
bool vsense_active_{false};
|
||||
bool step_state_{false};
|
||||
bool moving_{false};
|
||||
HighFrequencyLoopRequester high_freq_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from esphome import automation, core
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import media_player, micro_wake_word, microphone, speaker
|
||||
import esphome.config_validation as cv
|
||||
@@ -47,9 +47,6 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word"
|
||||
CONF_WAKE_WORD = "wake_word"
|
||||
|
||||
CONF_CONVERSATION_TIMEOUT = "conversation_timeout"
|
||||
CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout"
|
||||
# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h.
|
||||
TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000)
|
||||
|
||||
CONF_ON_TIMER_STARTED = "on_timer_started"
|
||||
CONF_ON_TIMER_UPDATED = "on_timer_updated"
|
||||
@@ -113,13 +110,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(
|
||||
CONF_CONVERSATION_TIMEOUT, default="300s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_TTS_PLAYBACK_START_TIMEOUT,
|
||||
default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT,
|
||||
): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(min=core.TimePeriod(milliseconds=1)),
|
||||
),
|
||||
cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range(
|
||||
min=0.0, min_included=False
|
||||
),
|
||||
@@ -228,10 +218,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN]))
|
||||
cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER]))
|
||||
cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT]))
|
||||
if (
|
||||
timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT]
|
||||
) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT:
|
||||
cg.add(var.set_tts_playback_start_timeout(timeout))
|
||||
|
||||
if CONF_ON_LISTENING in config:
|
||||
await automation.build_automation(
|
||||
|
||||
@@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() {
|
||||
}
|
||||
|
||||
void VoiceAssistant::start_playback_timeout_() {
|
||||
this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() {
|
||||
this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() {
|
||||
this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID);
|
||||
this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
|
||||
|
||||
|
||||
@@ -193,7 +193,6 @@ class VoiceAssistant final : public Component {
|
||||
void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; }
|
||||
void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; }
|
||||
void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; }
|
||||
void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; }
|
||||
void reset_conversation_id();
|
||||
|
||||
Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; }
|
||||
@@ -319,8 +318,6 @@ class VoiceAssistant final : public Component {
|
||||
|
||||
float volume_multiplier_;
|
||||
uint32_t conversation_timeout_;
|
||||
// Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py.
|
||||
uint32_t tts_playback_start_timeout_{2000};
|
||||
|
||||
Configuration config_{};
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
#define USE_ENTITY_DEVICE_CLASS
|
||||
#define USE_ENTITY_ICON
|
||||
#define USE_ENTITY_UNIT_OF_MEASUREMENT
|
||||
#define USE_ES8388_SELECT
|
||||
#define USE_ESP32_BLE_PSRAM
|
||||
#define USE_ESP32_CAMERA_JPEG_CONVERSION
|
||||
#define USE_ESP32_HOSTED
|
||||
|
||||
+218
-17
@@ -9,7 +9,8 @@ from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome import compiled_config, yaml_util
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -20,6 +21,10 @@ PLAIN_SCALAR = r"[^\s#\"'>|&*!%@`\[\]{},]+"
|
||||
TRAILER = r"(?P<quote>[\"']?){value}(?P=quote)(?P<trail>(?:\s+#.*)?\s*)$"
|
||||
|
||||
|
||||
class RestoreError(EsphomeError):
|
||||
"""A rollback left at least one file as it was written."""
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
"""The file as written, line endings included; read_file would fold them."""
|
||||
try:
|
||||
@@ -30,12 +35,25 @@ def read_text(path: Path) -> str:
|
||||
|
||||
@dataclass
|
||||
class LineEdit:
|
||||
"""One line to rewrite; ``old_line`` is what it held when located."""
|
||||
"""One line to rewrite, or with ``insert_after`` a line to add right
|
||||
after it; ``old_line`` is what the line held when it was located."""
|
||||
|
||||
path: Path
|
||||
line: int
|
||||
old_line: str
|
||||
new_line: str
|
||||
insert_after: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snapshot:
|
||||
"""A rewritten file's text before and after, and its mode before; the
|
||||
restore only puts ``original`` back over ``written``, never over the
|
||||
user's own edit."""
|
||||
|
||||
original: str
|
||||
written: str
|
||||
mode: int | None = None
|
||||
|
||||
|
||||
def field_line_re(
|
||||
@@ -63,17 +81,66 @@ def line_at(doc: Path, line_no: int) -> str:
|
||||
return lines[line_no]
|
||||
|
||||
|
||||
_DOMAIN = "yaml_edit"
|
||||
|
||||
|
||||
def own_documents() -> set[Path]:
|
||||
"""Every file the loader read for this configuration, found once per run.
|
||||
A file behind an include path with a substitution that gave nothing but
|
||||
substitutions is the one kind this misses."""
|
||||
if (documents := CORE.data.get(_DOMAIN)) is None:
|
||||
documents = CORE.data[_DOMAIN] = _own_documents()
|
||||
return documents
|
||||
|
||||
|
||||
def _own_documents() -> set[Path]:
|
||||
documents = {str(CORE.config_path)}
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if (rng := getattr(node, "esp_range", None)) is not None:
|
||||
documents.add(rng.start_mark.document)
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
walk(key)
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
walk(value)
|
||||
|
||||
walk(CORE.raw_config or {})
|
||||
# A file that only gave substitutions leaves no node behind; the tracker
|
||||
# also lists a secrets file the loader only tried, hence the is_file check
|
||||
found = yaml_util.discover_user_yaml_files(CORE.config_path, clear_secrets=False)
|
||||
if found.load_errors: # an incomplete set would misplace a key
|
||||
raise EsphomeError(
|
||||
"Could not read every file of the configuration: "
|
||||
+ "; ".join(found.load_errors)
|
||||
)
|
||||
documents.update(map(str, found.files))
|
||||
return {p.resolve() for d in documents if (p := Path(d)).is_file()}
|
||||
|
||||
|
||||
def source_of(mapping: ConfigType, name: str) -> tuple[Path, int] | None:
|
||||
"""The file and line ``name:`` was read from, None when validation added
|
||||
it or a merge key brought it in from an anchor elsewhere. Mapping keys
|
||||
keep their range through validation, values may not."""
|
||||
"""The file and line ``name:`` was read from; None when validation added
|
||||
it or a merge key brought it in from an anchor elsewhere."""
|
||||
rng = getattr(next((k for k in mapping if k == name), None), "esp_range", None)
|
||||
if rng is None:
|
||||
return None
|
||||
# An included mapping carries the `!include` line of its parent, so only
|
||||
# a key in the same document can be placed against the mapping
|
||||
if (own := getattr(mapping, "esp_range", None)) is None:
|
||||
return None # a mapping built in code, its keys are not on its lines
|
||||
# merge_config keeps the first mapping's keys with the last one's
|
||||
# values, so only the value's range names the line that won, and
|
||||
# only when the key is on it (a block value starts on a line of its own)
|
||||
rng = getattr(mapping.get(name), "esp_range", None)
|
||||
if rng is None:
|
||||
return None
|
||||
doc, line = Path(rng.start_mark.document), rng.start_mark.line
|
||||
if doc.resolve() not in own_documents() or not field_line_re(name).match(
|
||||
line_at(doc, line)
|
||||
):
|
||||
return None
|
||||
return doc, line
|
||||
# An included mapping carries its parent's `!include` line, so only a
|
||||
# key in the same document can be checked against the mapping's lines
|
||||
if (
|
||||
rng.start_mark.document == own.start_mark.document
|
||||
and not own.start_mark.line <= rng.start_mark.line <= own.end_mark.line
|
||||
@@ -82,13 +149,46 @@ def source_of(mapping: ConfigType, name: str) -> tuple[Path, int] | None:
|
||||
return Path(rng.start_mark.document), rng.start_mark.line
|
||||
|
||||
|
||||
def write_keeping_mode(path: Path, text: str, like: Path | None = None) -> None:
|
||||
"""Write with the mode of ``like`` (default: the file itself) rather
|
||||
than write_file's 0644; a 0600 secrets file stays 0600."""
|
||||
def editable_file(path: Path) -> Path:
|
||||
"""``path`` resolved, so a symlink's target is edited; refuses a file
|
||||
outside the config dir or under the build data."""
|
||||
resolved = path.resolve()
|
||||
if (
|
||||
not path.is_file()
|
||||
or not resolved.is_relative_to(CORE.config_dir.resolve())
|
||||
or resolved.is_relative_to(CORE.data_dir.resolve())
|
||||
):
|
||||
raise EsphomeError(
|
||||
f"{path} is not an editable file in the configuration directory"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def source_line(mapping: ConfigType, name: str) -> tuple[Path, int, str]:
|
||||
"""The file, line number and text ``name:`` was read from."""
|
||||
if (source := source_of(mapping, name)) is None:
|
||||
raise EsphomeError(f"'{name}' was not read from a file")
|
||||
doc, line_no = source
|
||||
# Returned as the loader saw it: a `!secret` in a symlinked include
|
||||
# resolves beside the link
|
||||
editable_file(doc)
|
||||
return doc, line_no, line_at(doc, line_no)
|
||||
|
||||
|
||||
def mode_of(path: Path) -> int:
|
||||
try:
|
||||
mode = stat.S_IMODE((like or path).stat().st_mode)
|
||||
return stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError as err:
|
||||
raise EsphomeError(f"Could not read the mode of {like or path}: {err}") from err
|
||||
raise EsphomeError(f"Could not read the mode of {path}: {err}") from err
|
||||
|
||||
|
||||
def write_keeping_mode(
|
||||
path: Path, text: str, like: Path | None = None, mode: int | None = None
|
||||
) -> None:
|
||||
"""Write with ``mode``, else the mode of ``like`` (default: the file
|
||||
itself), rather than write_file's 0644; a 0600 secrets file stays 0600."""
|
||||
if mode is None:
|
||||
mode = mode_of(like or path)
|
||||
try:
|
||||
write_file(path, text, private=True)
|
||||
except EsphomeError as err:
|
||||
@@ -103,12 +203,113 @@ def write_keeping_mode(path: Path, text: str, like: Path | None = None) -> None:
|
||||
|
||||
|
||||
def rewritten_text(original: str, edits: list[LineEdit]) -> str:
|
||||
"""``original`` with the edits applied; every edit must still find the
|
||||
line it was located on."""
|
||||
"""``original`` with the edits applied; a line that changed is refused."""
|
||||
lines = original.splitlines(keepends=True)
|
||||
for edit in edits:
|
||||
file_newline = "\r\n" if "\r\n" in original else "\n"
|
||||
# Bottom up, so an insertion never shifts a later edit; insertions under
|
||||
# one line keep their order
|
||||
order = sorted(
|
||||
enumerate(edits), key=lambda ie: (ie[1].line, ie[1].insert_after, ie[0])
|
||||
)
|
||||
for _, edit in reversed(order):
|
||||
text = lines[edit.line].rstrip("\r\n") if edit.line < len(lines) else None
|
||||
if text != edit.old_line:
|
||||
raise EsphomeError(f"{edit.path}:{edit.line + 1} changed since it was read")
|
||||
lines[edit.line] = edit.new_line + lines[edit.line][len(text) :]
|
||||
ending = lines[edit.line][len(text) :]
|
||||
if edit.insert_after:
|
||||
# A last line without a newline gets the file's own kind
|
||||
lines[edit.line] = text + (ending or file_newline)
|
||||
lines.insert(edit.line + 1, edit.new_line + ending)
|
||||
else:
|
||||
lines[edit.line] = edit.new_line + ending
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
def apply_line_edits(edits: list[LineEdit]) -> dict[Path, Snapshot]:
|
||||
"""Rewrite the located lines in place; returns each file's text before
|
||||
and after for restore_files. Nothing is written while any line is stale,
|
||||
and a file that no longer loads is put back here."""
|
||||
by_path: dict[Path, list[LineEdit]] = {}
|
||||
seen_as: dict[Path, Path] = {} # the path the loader read a file by
|
||||
for edit in edits:
|
||||
resolved = editable_file(edit.path)
|
||||
by_path.setdefault(resolved, []).append(edit)
|
||||
seen_as.setdefault(resolved, edit.path)
|
||||
originals = {}
|
||||
for path, own in by_path.items():
|
||||
original = read_text(path)
|
||||
originals[path] = Snapshot(
|
||||
original, rewritten_text(original, own), mode_of(path)
|
||||
)
|
||||
try:
|
||||
for path, snapshot in originals.items():
|
||||
write_keeping_mode(path, snapshot.written)
|
||||
# Reloaded as the loader saw them: a relative !include inside a
|
||||
# symlinked file resolves beside the link
|
||||
reloads = [seen_as[path] for path in originals]
|
||||
if CORE.config_path.resolve() not in originals:
|
||||
# Its !secret tags register a value written elsewhere
|
||||
reloads.append(CORE.config_path)
|
||||
for path in reloads:
|
||||
try:
|
||||
# The secret registry stays: later output still redacts
|
||||
yaml_util.load_yaml(
|
||||
path, clear_secrets=False, track_document_range=False
|
||||
)
|
||||
except EsphomeError as err:
|
||||
raise EsphomeError(f"{path} no longer loads: {err}") from err
|
||||
compiled_config.invalidate_compiled_config()
|
||||
except BaseException as err:
|
||||
try:
|
||||
restore_files(originals)
|
||||
except RestoreError as restore_err:
|
||||
if not isinstance(err, Exception):
|
||||
err.add_note(str(restore_err)) # an interrupt stays one
|
||||
raise err from None
|
||||
raise RestoreError(f"{err}; {restore_err}") from err
|
||||
raise
|
||||
return originals
|
||||
|
||||
|
||||
def restore_files(originals: dict[Path, Snapshot]) -> None:
|
||||
"""Put back what apply_line_edits wrote, mode included; a file that
|
||||
failed or changed meanwhile is left alone and reported. Every file is
|
||||
tried whatever went wrong before; an interrupt or exit is raised again
|
||||
at the end with the report as a note."""
|
||||
failed: list[str] = []
|
||||
stop: BaseException | None = None
|
||||
|
||||
def report(what: str, err: BaseException) -> None:
|
||||
nonlocal stop
|
||||
if isinstance(err, Exception):
|
||||
why = str(err) or type(err).__name__
|
||||
else: # an interrupt or exit says what it is, not its code
|
||||
why = (
|
||||
"interrupted"
|
||||
if isinstance(err, KeyboardInterrupt)
|
||||
else type(err).__name__
|
||||
)
|
||||
stop = err
|
||||
failed.append(f"{what}: {why}")
|
||||
|
||||
for path, snapshot in originals.items():
|
||||
try:
|
||||
current = read_text(path)
|
||||
if current == snapshot.original:
|
||||
continue # never written, or already put back
|
||||
if current != snapshot.written:
|
||||
raise EsphomeError("changed since it was written, left as is")
|
||||
write_keeping_mode(path, snapshot.original, mode=snapshot.mode)
|
||||
except BaseException as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
report(str(path), err)
|
||||
try:
|
||||
compiled_config.invalidate_compiled_config()
|
||||
except BaseException as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
report("the validated caches", err)
|
||||
if not failed:
|
||||
return
|
||||
message = "Could not restore " + "; ".join(failed)
|
||||
if stop is not None:
|
||||
stop.add_note(message)
|
||||
raise stop
|
||||
raise RestoreError(message)
|
||||
|
||||
@@ -637,9 +637,13 @@ class DiscoveredYamlFiles:
|
||||
load_errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles:
|
||||
def discover_user_yaml_files(
|
||||
config_path: Path, clear_secrets: bool = True
|
||||
) -> DiscoveredYamlFiles:
|
||||
"""Fresh-re-parse ``config_path`` and report every file the YAML loader
|
||||
pulled in, plus which of them came in under a secrets filename.
|
||||
``clear_secrets`` is passed to the load; False keeps the redaction
|
||||
registry of a configuration loaded earlier in the run.
|
||||
|
||||
Does NOT run schema validation, substitutions, or package resolution — so
|
||||
component-internal YAML loaded by validators (LVGL helpers, dashboard
|
||||
@@ -663,7 +667,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles:
|
||||
_load_listeners.append(_capture_secret)
|
||||
try:
|
||||
try:
|
||||
data = load_yaml(config_path)
|
||||
data = load_yaml(config_path, clear_secrets=clear_secrets)
|
||||
except EsphomeError as err:
|
||||
return DiscoveredYamlFiles(
|
||||
list(loaded), secrets, load_errors=[f"{config_path}: {err}"]
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32c6
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
openthread:
|
||||
channel: 13
|
||||
network_name: OpenThread-8f28
|
||||
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
|
||||
pan_id: 0x8f28
|
||||
core_options:
|
||||
OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1"
|
||||
OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5"
|
||||
@@ -1,16 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32c6
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
network:
|
||||
enable_ipv6: true
|
||||
|
||||
openthread:
|
||||
channel: 13
|
||||
network_name: OpenThread-8f28
|
||||
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
|
||||
pan_id: 0x8f28
|
||||
@@ -1,22 +1,11 @@
|
||||
"""Tests for the openthread component."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
"""Tests for the openthread component schema."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import (
|
||||
KEY_ESP32,
|
||||
KEY_SDKCONFIG_OPTIONS,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32C6,
|
||||
)
|
||||
from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE
|
||||
from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework, Toolchain
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6
|
||||
from esphome.components.openthread import CONFIG_SCHEMA
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import HexInt
|
||||
from esphome.yaml_util import Sensitive
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
@@ -48,153 +37,3 @@ def test_credentials_are_sensitive(
|
||||
assert isinstance(validated[field], Sensitive)
|
||||
assert validated[field] == expected
|
||||
assert isinstance(validated[field], HexInt) == isinstance(expected, int)
|
||||
|
||||
|
||||
def _generate_in(
|
||||
tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str]
|
||||
) -> tuple[Path, MagicMock]:
|
||||
"""Generate from ``yaml_text`` written to tmp_path, so the build directory lands there.
|
||||
|
||||
Returns the header path and the mocked ``clean_build``.
|
||||
"""
|
||||
config_path = tmp_path / "test.yaml"
|
||||
config_path.write_text(yaml_text, encoding="utf-8")
|
||||
with patch("esphome.components.openthread.clean_build") as clean_build:
|
||||
generate_main(config_path)
|
||||
return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build
|
||||
|
||||
|
||||
def test_core_options_written_to_header(
|
||||
tmp_path: Path,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
header, _ = _generate_in(
|
||||
tmp_path,
|
||||
component_config_path("custom_header.yaml").read_text(encoding="utf-8"),
|
||||
generate_main,
|
||||
)
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True
|
||||
assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR
|
||||
assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE
|
||||
assert header.read_text(encoding="utf-8") == (
|
||||
"// Auto-generated by ESPHome from openthread core_options - do not edit\n"
|
||||
"#pragma once\n\n"
|
||||
"#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n"
|
||||
"#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n"
|
||||
)
|
||||
|
||||
|
||||
def test_no_header_options_no_header(
|
||||
tmp_path: Path,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
header, clean_build = _generate_in(
|
||||
tmp_path,
|
||||
component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"),
|
||||
generate_main,
|
||||
)
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig
|
||||
assert not header.exists()
|
||||
clean_build.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "cleans"),
|
||||
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
|
||||
)
|
||||
def test_header_change_cleans_only_on_platformio(
|
||||
toolchain: Toolchain,
|
||||
cleans: bool,
|
||||
tmp_path: Path,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
source = component_config_path("custom_header.yaml").read_text(encoding="utf-8")
|
||||
CORE.toolchain = toolchain
|
||||
_, clean_build = _generate_in(tmp_path, source, generate_main)
|
||||
assert clean_build.called is cleans
|
||||
# Unchanged content: no clean on either toolchain.
|
||||
CORE.reset()
|
||||
CORE.toolchain = toolchain
|
||||
_, clean_build = _generate_in(tmp_path, source, generate_main)
|
||||
clean_build.assert_not_called()
|
||||
# A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread.
|
||||
changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"')
|
||||
assert changed != source
|
||||
CORE.reset()
|
||||
CORE.toolchain = toolchain
|
||||
header, clean_build = _generate_in(tmp_path, changed, generate_main)
|
||||
assert clean_build.called is cleans
|
||||
assert (
|
||||
"#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n"
|
||||
in header.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"CONFIG_OPENTHREAD_JOINER",
|
||||
"openthread_config_x",
|
||||
"OT_CONFIG_X",
|
||||
OPENTHREAD_CONFIG_PREFIX,
|
||||
],
|
||||
)
|
||||
def test_core_option_without_prefix_rejected(
|
||||
name: str, set_core_config: SetCoreConfigCallable
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX):
|
||||
CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"],
|
||||
)
|
||||
def test_core_option_bad_characters_rejected(
|
||||
name: str, set_core_config: SetCoreConfigCallable
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"):
|
||||
CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "message"),
|
||||
[
|
||||
("", "must not be empty"),
|
||||
(" ", "must not be empty"),
|
||||
("1\n#define X 2", "must be a single line"),
|
||||
("1\r2", "must be a single line"),
|
||||
("1 \\", "must not end with"),
|
||||
("1 \\ ", "must not end with"),
|
||||
],
|
||||
)
|
||||
def test_core_option_bad_value_rejected(
|
||||
value: str, message: str, set_core_config: SetCoreConfigCallable
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match=message):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"network_key": hex(NETWORK_KEY),
|
||||
"core_options": {"OPENTHREAD_CONFIG_X": value},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.NRF52_ZEPHYR)
|
||||
with pytest.raises(cv.Invalid, match="ESP32"):
|
||||
CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}})
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Test playback timeout C++ generation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"])
|
||||
def test_playback_timeout_codegen(
|
||||
timeout: str | None,
|
||||
tmp_path: Path,
|
||||
generate_main: Callable[[Path], str],
|
||||
) -> None:
|
||||
config = tmp_path / "voice_assistant.yaml"
|
||||
config.write_text(
|
||||
"""esphome:
|
||||
name: test-voice-assistant
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
wifi:
|
||||
ssid: test-network
|
||||
api:
|
||||
i2s_audio:
|
||||
i2s_lrclk_pin: GPIO25
|
||||
i2s_bclk_pin: GPIO26
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
id: test_mic
|
||||
adc_type: external
|
||||
i2s_din_pin: GPIO34
|
||||
voice_assistant:
|
||||
microphone: test_mic
|
||||
"""
|
||||
+ (f" tts_playback_start_timeout: {timeout}\n" if timeout else "")
|
||||
)
|
||||
main = generate_main(config)
|
||||
if timeout in (None, "2s", "2000ms"):
|
||||
assert "set_tts_playback_start_timeout(" not in main
|
||||
else:
|
||||
milliseconds = 1 if timeout == "1ms" else 10000
|
||||
assert f"set_tts_playback_start_timeout({milliseconds});" in main
|
||||
@@ -17,9 +17,5 @@ select:
|
||||
es8388_id: es8388_parent
|
||||
dac_output:
|
||||
name: "DAC Output"
|
||||
id: es8388_test_dac_output_select
|
||||
initial_option: BOTH
|
||||
adc_input_mic:
|
||||
name: "ADC Input MIC"
|
||||
id: es8388_test_adc_input_mic_select
|
||||
initial_option: LINE2
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
for platform in ("sensor", "binary_sensor"):
|
||||
CORE.testing_ensure_platform_registered(platform)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"]
|
||||
@@ -1,62 +0,0 @@
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Fan Running"
|
||||
id: fan_running
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Fan Speed Percent"
|
||||
id: fan_speed_percent
|
||||
unit_of_measurement: "%"
|
||||
|
||||
# Test with lambdas
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Lambda"
|
||||
id: filter_lifetime_lambda
|
||||
max_lifetime: 365d
|
||||
is_on: !lambda 'return true;'
|
||||
current_speed: !lambda 'return 50.0;'
|
||||
update_interval: 60s
|
||||
runtime_hours:
|
||||
name: "Filter Runtime Hours Lambda"
|
||||
remaining_days:
|
||||
name: "Filter Remaining Days Lambda"
|
||||
|
||||
# Test with sensor references
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Sensor"
|
||||
id: filter_lifetime_sensor
|
||||
max_lifetime: 365d
|
||||
is_on_sensor: fan_running
|
||||
current_speed_sensor: fan_speed_percent
|
||||
update_interval: 60s
|
||||
runtime_hours:
|
||||
name: "Filter Runtime Hours Sensor"
|
||||
remaining_days:
|
||||
name: "Filter Remaining Days Sensor"
|
||||
|
||||
# Test with defaults (always on at 100%)
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Default"
|
||||
id: filter_lifetime_default
|
||||
max_lifetime: 180d
|
||||
update_interval: 60s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Reset Filter Lambda"
|
||||
on_press:
|
||||
- filter_lifetime.reset_filter:
|
||||
id: filter_lifetime_lambda
|
||||
|
||||
- platform: template
|
||||
name: "Reset Filter Sensor"
|
||||
on_press:
|
||||
- filter_lifetime.reset_filter:
|
||||
id: filter_lifetime_sensor
|
||||
|
||||
- platform: template
|
||||
name: "Reset Filter Default"
|
||||
on_press:
|
||||
- filter_lifetime.reset_filter:
|
||||
id: filter_lifetime_default
|
||||
@@ -1,8 +0,0 @@
|
||||
sensor:
|
||||
- platform: filter_lifetime
|
||||
name: "Filter Lifetime Lambda Only"
|
||||
max_lifetime: 180d
|
||||
is_on: !lambda 'return true;'
|
||||
current_speed: !lambda 'return 75.0f;'
|
||||
remaining_days:
|
||||
name: "Filter Remaining Days Lambda Only"
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
filter_lifetime: !include lambda_only.yaml
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
filter_lifetime: !include common.yaml
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
filter_lifetime: !include common.yaml
|
||||
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
filter_lifetime: !include common.yaml
|
||||
@@ -1,224 +0,0 @@
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#include "esphome/components/filter_lifetime/filter_lifetime.h"
|
||||
#include "esphome/components/host/preferences.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::filter_lifetime::testing {
|
||||
|
||||
class EntityIdentity : public EntityBase {
|
||||
public:
|
||||
static void configure(EntityBase &entity, const char *name, uint32_t hash) {
|
||||
auto configure = &EntityIdentity::configure_entity_;
|
||||
(entity.*configure)(name, hash, 0);
|
||||
}
|
||||
};
|
||||
|
||||
class TestableFilterLifetime : public FilterLifetime {
|
||||
public:
|
||||
using FilterLifetime::last_update_;
|
||||
using FilterLifetime::runtime_seconds_;
|
||||
};
|
||||
|
||||
static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static constexpr uint32_t MAX_MINUTES = 600; // 10 hours
|
||||
static constexpr uint32_t FILTER_HASH = 0x104001;
|
||||
|
||||
class FilterLifetimeTest : public ::testing::Test {
|
||||
protected:
|
||||
TestableFilterLifetime filter_;
|
||||
sensor::Sensor runtime_hours_;
|
||||
sensor::Sensor remaining_days_;
|
||||
float last_state_{NAN};
|
||||
unsigned publications_{0};
|
||||
|
||||
void SetUp() override {
|
||||
new (&App) Application;
|
||||
App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21);
|
||||
host::setup_preferences();
|
||||
host::host_preferences->reset();
|
||||
g_is_on = true;
|
||||
g_speed = 100.0f;
|
||||
this->attach(this->filter_);
|
||||
this->filter_.add_on_state_callback([this](float value) {
|
||||
this->last_state_ = value;
|
||||
++this->publications_;
|
||||
});
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
host::host_preferences->reset();
|
||||
App.~Application();
|
||||
}
|
||||
|
||||
void attach(FilterLifetime &filter) {
|
||||
EntityIdentity::configure(filter, "Filter", FILTER_HASH);
|
||||
filter.set_max_lifetime(MAX_MINUTES);
|
||||
filter.set_is_on_lambda([]() { return g_is_on; });
|
||||
filter.set_current_speed_lambda([]() { return g_speed; });
|
||||
filter.set_runtime_hours_sensor(&this->runtime_hours_);
|
||||
filter.set_remaining_days_sensor(&this->remaining_days_);
|
||||
}
|
||||
|
||||
// Run one update() as if elapsed_ms passed since the previous one
|
||||
void elapse(uint32_t elapsed_ms) {
|
||||
this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms;
|
||||
this->filter_.update();
|
||||
}
|
||||
|
||||
bool stored(uint32_t &value) { return this->filter_.make_entity_preference<uint32_t>().load(&value); }
|
||||
|
||||
static float expected_percent(uint32_t runtime_seconds) {
|
||||
return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) {
|
||||
this->filter_.setup();
|
||||
EXPECT_EQ(this->publications_, 1u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
|
||||
EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f));
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) {
|
||||
this->filter_.setup();
|
||||
g_speed = 50.0f;
|
||||
this->elapse(60000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 30u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30));
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f);
|
||||
EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f));
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) {
|
||||
this->filter_.setup();
|
||||
g_speed = 25.0f;
|
||||
for (int i = 0; i < 3; i++)
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) {
|
||||
this->filter_.setup();
|
||||
g_is_on = false;
|
||||
this->elapse(60000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
EXPECT_EQ(this->publications_, 2u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) {
|
||||
this->filter_.setup();
|
||||
g_speed = 250.0f;
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
g_speed = NAN;
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
g_speed = -5.0f;
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) {
|
||||
sensor::Sensor speed;
|
||||
this->filter_.set_current_speed_lambda(nullptr);
|
||||
this->filter_.set_current_speed_sensor(&speed);
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
speed.state = 50.0f;
|
||||
this->elapse(2000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) {
|
||||
binary_sensor::BinarySensor running;
|
||||
this->filter_.set_is_on_lambda(nullptr);
|
||||
this->filter_.set_is_on_sensor(&running);
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
running.publish_state(true);
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
running.publish_state(false);
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) {
|
||||
this->filter_.set_is_on_lambda(nullptr);
|
||||
this->filter_.set_current_speed_lambda(nullptr);
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) {
|
||||
this->filter_.setup();
|
||||
uint32_t value = 0;
|
||||
this->elapse(3599000);
|
||||
EXPECT_FALSE(this->stored(value));
|
||||
this->elapse(1000);
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 3600u);
|
||||
this->elapse(1000);
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 3600u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) {
|
||||
this->filter_.setup();
|
||||
this->elapse(1000);
|
||||
this->filter_.on_shutdown();
|
||||
uint32_t value = 0;
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 1u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) {
|
||||
this->filter_.setup();
|
||||
this->elapse(1234000);
|
||||
this->filter_.on_shutdown();
|
||||
|
||||
TestableFilterLifetime restored;
|
||||
this->attach(restored);
|
||||
restored.setup();
|
||||
EXPECT_EQ(restored.runtime_seconds_, 1234u);
|
||||
EXPECT_FLOAT_EQ(restored.state, expected_percent(1234));
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) {
|
||||
this->filter_.setup();
|
||||
this->elapse(5000);
|
||||
this->filter_.reset_filter();
|
||||
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
|
||||
uint32_t value = 1;
|
||||
ASSERT_TRUE(this->stored(value));
|
||||
EXPECT_EQ(value, 0u);
|
||||
}
|
||||
|
||||
TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) {
|
||||
this->filter_.set_max_lifetime(1);
|
||||
this->filter_.setup();
|
||||
this->elapse(120000);
|
||||
EXPECT_FLOAT_EQ(this->last_state_, 0.0f);
|
||||
EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f);
|
||||
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f);
|
||||
}
|
||||
|
||||
} // namespace esphome::filter_lifetime::testing
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -1,45 +0,0 @@
|
||||
climate:
|
||||
- platform: haier_tundra
|
||||
name: Haier AC A
|
||||
id: my_haier_ac_a
|
||||
transmitter_id: xmitr
|
||||
|
||||
- platform: haier_tundra
|
||||
name: Haier AC B
|
||||
id: my_haier_ac_b
|
||||
transmitter_id: xmitr
|
||||
unit: b
|
||||
|
||||
switch:
|
||||
- platform: haier_tundra
|
||||
haier_tundra_id: my_haier_ac_a
|
||||
quiet_mode:
|
||||
name: "Quiet Mode (Unit A)"
|
||||
turbo_mode:
|
||||
name: "Turbo Mode (Unit A)"
|
||||
health_mode:
|
||||
name: "Health Mode (Unit A)"
|
||||
|
||||
- platform: haier_tundra
|
||||
haier_tundra_id: my_haier_ac_b
|
||||
quiet_mode:
|
||||
name: "Quiet Mode (Unit B)"
|
||||
turbo_mode:
|
||||
name: "Turbo Mode (Unit B)"
|
||||
health_mode:
|
||||
name: "Health Mode (Unit B)"
|
||||
|
||||
button:
|
||||
- platform: haier_tundra
|
||||
haier_tundra_id: my_haier_ac_a
|
||||
light:
|
||||
name: "Toggle Light (Unit A)"
|
||||
self_clean:
|
||||
name: "Start Self Clean (Unit A)"
|
||||
|
||||
- platform: haier_tundra
|
||||
haier_tundra_id: my_haier_ac_b
|
||||
light:
|
||||
name: "Toggle Light (Unit B)"
|
||||
self_clean:
|
||||
name: "Start Self Clean (Unit B)"
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
|
||||
haier_tundra: !include common.yaml
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
|
||||
haier_tundra: !include common.yaml
|
||||
@@ -1,73 +0,0 @@
|
||||
sensor:
|
||||
- platform: icm20689
|
||||
type: temperature
|
||||
name: "ICM20689 Temperature"
|
||||
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: acceleration_x
|
||||
name: "ICM20689 Accel X"
|
||||
accuracy_decimals: 4
|
||||
filters:
|
||||
- sliding_window_moving_average:
|
||||
window_size: 4
|
||||
send_every: 1
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: acceleration_y
|
||||
name: "ICM20689 Accel Y"
|
||||
accuracy_decimals: 4
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: acceleration_z
|
||||
name: "ICM20689 Accel Z"
|
||||
accuracy_decimals: 4
|
||||
|
||||
# Gyroscope axes (unit: °/s)
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: gyroscope_x
|
||||
name: "ICM20689 Gyro X"
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: gyroscope_y
|
||||
name: "ICM20689 Gyro Y"
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: gyroscope_z
|
||||
name: "ICM20689 Gyro Z"
|
||||
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: angular_rate_x
|
||||
name: "ICM20689 Angular Rate X"
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: angular_rate_y
|
||||
name: "ICM20689 Angular Rate Y"
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: angular_rate_z
|
||||
name: "ICM20689 Angular Rate Z"
|
||||
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: pitch
|
||||
name: "ICM20689 Pitch"
|
||||
- platform: motion
|
||||
motion_id: icm20689_motion
|
||||
type: roll
|
||||
name: "ICM20689 Roll"
|
||||
|
||||
motion:
|
||||
- platform: icm20689
|
||||
id: icm20689_motion
|
||||
cs_pin: GPIO5
|
||||
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
|
||||
accelerometer_range: 4G
|
||||
# Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS
|
||||
gyroscope_range: 500DPS
|
||||
axis_map:
|
||||
x: y
|
||||
y: x
|
||||
z: -z
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
|
||||
icm20689: !include common.yaml
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
|
||||
icm20689: !include common.yaml
|
||||
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
|
||||
icm20689: !include common.yaml
|
||||
@@ -9,5 +9,3 @@ openthread:
|
||||
pskc: 0xc23a76e98f1a6483639b1ac1271e2e27
|
||||
mesh_local_prefix: fd53:145f:ed22:ad81::/64
|
||||
output_power: 1dBm
|
||||
core_options:
|
||||
OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5"
|
||||
|
||||
@@ -8,16 +8,6 @@ pcf8574:
|
||||
address: 0x22
|
||||
pcf8575: false
|
||||
interrupt_pin: ${interrupt_pin}
|
||||
reset: true
|
||||
- id: pcf8574_preserved
|
||||
i2c_id: i2c_bus
|
||||
address: 0x23
|
||||
reset: false
|
||||
- id: pcf8575_preserved
|
||||
i2c_id: i2c_bus
|
||||
address: 0x24
|
||||
pcf8575: true
|
||||
reset: false
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
@@ -28,18 +18,6 @@ binary_sensor:
|
||||
number: 1
|
||||
mode: INPUT
|
||||
inverted: true
|
||||
- platform: gpio
|
||||
id: pcf8574_preserved_input
|
||||
pin:
|
||||
pcf8574: pcf8574_preserved
|
||||
number: 1
|
||||
mode: INPUT
|
||||
- platform: gpio
|
||||
id: pcf8575_preserved_input
|
||||
pin:
|
||||
pcf8574: pcf8575_preserved
|
||||
number: 15
|
||||
mode: INPUT
|
||||
|
||||
output:
|
||||
- platform: gpio
|
||||
@@ -49,13 +27,3 @@ output:
|
||||
number: 0
|
||||
mode: OUTPUT
|
||||
inverted: false
|
||||
- platform: gpio
|
||||
id: pcf8574_preserved_output
|
||||
pin:
|
||||
pcf8574: pcf8574_preserved
|
||||
number: 0
|
||||
- platform: gpio
|
||||
id: pcf8575_preserved_output
|
||||
pin:
|
||||
pcf8574: pcf8575_preserved
|
||||
number: 8
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/pcf8574/pcf8574.h"
|
||||
|
||||
namespace esphome::pcf8574::testing {
|
||||
|
||||
// Model quasi-bidirectional readback separately from the output latch.
|
||||
class PortBus : public i2c::I2CBus {
|
||||
public:
|
||||
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer,
|
||||
size_t read_count) override {
|
||||
EXPECT_EQ(address, 0x21);
|
||||
if (read_count != 0) {
|
||||
EXPECT_EQ(read_count, this->width);
|
||||
this->reads++;
|
||||
if (this->fail_read)
|
||||
return i2c::ERROR_TIMEOUT;
|
||||
uint16_t levels = this->latch & ~this->external_low;
|
||||
for (size_t i = 0; i < read_count; i++)
|
||||
read_buffer[i] = levels >> (8 * i);
|
||||
}
|
||||
if (write_count != 0) {
|
||||
EXPECT_EQ(write_count, this->width);
|
||||
this->writes++;
|
||||
this->latch = write_buffer[0];
|
||||
if (write_count == 2)
|
||||
this->latch |= uint16_t(write_buffer[1]) << 8;
|
||||
}
|
||||
return i2c::ERROR_OK;
|
||||
}
|
||||
|
||||
size_t width{1};
|
||||
uint16_t latch{0};
|
||||
uint16_t external_low{0};
|
||||
unsigned reads{0};
|
||||
bool fail_read{false};
|
||||
unsigned writes{0};
|
||||
};
|
||||
|
||||
class PCF8574ResetTest : public ::testing::TestWithParam<bool> {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->bus_.width = GetParam() ? 2 : 1;
|
||||
this->mask_ = GetParam() ? 0xFFFF : 0xFF;
|
||||
this->high_pin_ = GetParam() ? 15 : 7;
|
||||
this->bus_.latch = 0xA55A & this->mask_;
|
||||
this->component_.set_i2c_bus(&this->bus_);
|
||||
this->component_.set_i2c_address(0x21);
|
||||
this->component_.set_pcf8575(GetParam());
|
||||
}
|
||||
|
||||
void configure_input_(uint8_t number) {
|
||||
PCF8574GPIOPin pin;
|
||||
pin.set_parent(&this->component_);
|
||||
pin.set_pin(number);
|
||||
pin.set_flags(gpio::FLAG_INPUT);
|
||||
EXPECT_EQ(this->bus_.writes, 0);
|
||||
EXPECT_EQ(this->bus_.reads, 0);
|
||||
}
|
||||
|
||||
PortBus bus_;
|
||||
PCF8574Component component_;
|
||||
uint16_t mask_;
|
||||
uint8_t high_pin_;
|
||||
};
|
||||
|
||||
TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) {
|
||||
this->configure_input_(1);
|
||||
|
||||
this->component_.setup();
|
||||
ASSERT_EQ(this->bus_.writes, 1);
|
||||
EXPECT_EQ(this->bus_.latch, this->mask_);
|
||||
EXPECT_EQ(this->bus_.reads, 2);
|
||||
|
||||
this->component_.pin_mode(0, gpio::FLAG_OUTPUT);
|
||||
EXPECT_EQ(this->bus_.writes, 1);
|
||||
|
||||
this->component_.digital_write(0, false);
|
||||
EXPECT_EQ(this->bus_.latch, this->mask_ & ~1);
|
||||
}
|
||||
|
||||
TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) {
|
||||
this->component_.set_reset(false);
|
||||
const uint16_t initial = this->bus_.latch;
|
||||
|
||||
this->component_.setup();
|
||||
EXPECT_EQ(this->bus_.writes, 0);
|
||||
EXPECT_EQ(this->bus_.reads, 1);
|
||||
EXPECT_EQ(this->bus_.latch, initial);
|
||||
|
||||
// Consumers can write before calling their GPIO pin's setup().
|
||||
this->component_.digital_write(0, true);
|
||||
EXPECT_EQ(this->bus_.latch, initial | 1);
|
||||
|
||||
this->component_.pin_mode(0, gpio::FLAG_OUTPUT);
|
||||
this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT);
|
||||
const uint16_t high_pin_mask = 1 << this->high_pin_;
|
||||
this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0);
|
||||
const uint16_t expected = (initial | 1) ^ high_pin_mask;
|
||||
EXPECT_EQ(this->bus_.latch, expected);
|
||||
|
||||
this->component_.digital_write(0, false);
|
||||
EXPECT_EQ(this->bus_.latch, expected & ~1);
|
||||
ASSERT_EQ(this->bus_.writes, 3);
|
||||
}
|
||||
|
||||
TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) {
|
||||
const uint16_t inputs = (1 << 1) | (1 << this->high_pin_);
|
||||
this->bus_.latch |= inputs;
|
||||
const uint16_t initial = this->bus_.latch;
|
||||
this->bus_.external_low = inputs;
|
||||
|
||||
this->component_.set_reset(false);
|
||||
this->configure_input_(1);
|
||||
this->configure_input_(this->high_pin_);
|
||||
this->component_.setup();
|
||||
EXPECT_EQ(this->bus_.writes, 0);
|
||||
|
||||
this->component_.digital_write(0, true);
|
||||
EXPECT_EQ(this->bus_.latch, initial | 1);
|
||||
|
||||
this->component_.pin_mode(1, gpio::FLAG_INPUT);
|
||||
this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT);
|
||||
EXPECT_EQ(this->bus_.latch, initial | 1);
|
||||
EXPECT_FALSE(this->component_.digital_read(1));
|
||||
}
|
||||
|
||||
TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) {
|
||||
this->component_.set_reset(false);
|
||||
this->configure_input_(1);
|
||||
const uint16_t initial = this->bus_.latch;
|
||||
|
||||
this->component_.setup();
|
||||
this->component_.pin_mode(1, gpio::FLAG_INPUT);
|
||||
EXPECT_EQ(this->bus_.latch, initial);
|
||||
|
||||
this->component_.pin_mode(0, gpio::FLAG_OUTPUT);
|
||||
this->component_.digital_write(0, true);
|
||||
EXPECT_EQ(this->bus_.latch, initial | 1);
|
||||
}
|
||||
|
||||
TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) {
|
||||
const uint16_t loaded_pin = 1 << this->high_pin_;
|
||||
this->bus_.latch |= loaded_pin;
|
||||
this->bus_.external_low = loaded_pin;
|
||||
const uint16_t initial = this->bus_.latch;
|
||||
this->component_.set_reset(false);
|
||||
|
||||
this->component_.setup();
|
||||
this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT);
|
||||
EXPECT_EQ(this->bus_.writes, 0);
|
||||
EXPECT_EQ(this->bus_.latch, initial);
|
||||
|
||||
this->component_.digital_write(0, true);
|
||||
EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin);
|
||||
}
|
||||
|
||||
TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) {
|
||||
const uint16_t undeclared_pin = 1 << this->high_pin_;
|
||||
this->bus_.latch |= undeclared_pin;
|
||||
this->bus_.external_low = undeclared_pin;
|
||||
const uint16_t initial = this->bus_.latch;
|
||||
this->component_.set_reset(false);
|
||||
|
||||
this->component_.setup();
|
||||
EXPECT_EQ(this->bus_.writes, 0);
|
||||
EXPECT_EQ(this->bus_.latch, initial);
|
||||
|
||||
this->component_.digital_write(0, true);
|
||||
this->bus_.external_low = 0;
|
||||
EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin);
|
||||
EXPECT_FALSE(this->component_.digital_read(this->high_pin_));
|
||||
}
|
||||
|
||||
TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) {
|
||||
this->bus_.fail_read = true;
|
||||
|
||||
this->component_.set_reset(false);
|
||||
this->component_.setup();
|
||||
EXPECT_TRUE(this->component_.is_failed());
|
||||
|
||||
this->component_.pin_mode(1, gpio::FLAG_INPUT);
|
||||
this->component_.digital_write(0, true);
|
||||
EXPECT_EQ(this->bus_.writes, 0);
|
||||
}
|
||||
|
||||
INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool());
|
||||
|
||||
} // namespace esphome::pcf8574::testing
|
||||
@@ -23,10 +23,6 @@ esphome:
|
||||
then:
|
||||
- logger.log: my_script is running
|
||||
|
||||
globals:
|
||||
- id: nested_total
|
||||
type: int
|
||||
|
||||
script:
|
||||
- id: my_script
|
||||
mode: single
|
||||
@@ -75,16 +71,3 @@ script:
|
||||
count: 5
|
||||
then:
|
||||
- logger.log: looping!
|
||||
- repeat:
|
||||
count: 2
|
||||
then:
|
||||
- repeat:
|
||||
count: 2
|
||||
then:
|
||||
- delay: 200ms
|
||||
- lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);'
|
||||
- globals.set:
|
||||
id: nested_total
|
||||
value: !lambda |-
|
||||
int total = iteration;
|
||||
return total;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/script/script.h"
|
||||
|
||||
@@ -94,89 +93,4 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) {
|
||||
EXPECT_EQ(entity.value, 1);
|
||||
}
|
||||
|
||||
class StopWaitAction : public Action<const std::string &> {
|
||||
public:
|
||||
void play(const std::string &value) override {
|
||||
this->wait->stop_complex();
|
||||
this->seen = value;
|
||||
}
|
||||
Action<const std::string &> *wait{nullptr};
|
||||
std::string seen;
|
||||
};
|
||||
|
||||
// The chain after the wait may stop it, e.g. a script with mode: restart
|
||||
TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) {
|
||||
FakeScript script;
|
||||
ScriptWaitAction<FakeScript, const std::string &> wait(&script);
|
||||
StopWaitAction stop;
|
||||
stop.wait = &wait;
|
||||
ActionList<const std::string &> actions;
|
||||
actions.add_actions({&wait, &stop});
|
||||
|
||||
// With a second item waiting, stop() clears the queue while the first item's chain runs
|
||||
actions.play(std::string("argument longer than the small string buffer"));
|
||||
actions.play(std::string("second argument, also longer than the buffer"));
|
||||
script.running = false;
|
||||
wait.loop();
|
||||
EXPECT_EQ(stop.seen, "argument longer than the small string buffer");
|
||||
wait.loop();
|
||||
EXPECT_EQ(stop.seen, "argument longer than the small string buffer");
|
||||
}
|
||||
|
||||
class RestartAction : public Action<const std::string &> {
|
||||
public:
|
||||
void play(const std::string &value) override {
|
||||
this->seen.push_back(value);
|
||||
if (this->seen.size() > 1)
|
||||
return;
|
||||
// What a script with mode: restart does: stop, then run again while the awaited script is busy
|
||||
this->wait->stop_complex();
|
||||
this->script->running = true;
|
||||
this->actions->play(std::string("restarted, longer than the small string buffer"));
|
||||
}
|
||||
Action<const std::string &> *wait{nullptr};
|
||||
FakeScript *script{nullptr};
|
||||
ActionList<const std::string &> *actions{nullptr};
|
||||
std::vector<std::string> seen;
|
||||
};
|
||||
|
||||
// The old pop_front() after the chain removed the item queued by the restart
|
||||
TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) {
|
||||
FakeScript script;
|
||||
ScriptWaitAction<FakeScript, const std::string &> wait(&script);
|
||||
RestartAction restart;
|
||||
ActionList<const std::string &> actions;
|
||||
actions.add_actions({&wait, &restart});
|
||||
restart.wait = &wait;
|
||||
restart.script = &script;
|
||||
restart.actions = &actions;
|
||||
|
||||
// With a second item waiting, the stop really clears the queue; the restart then queues a new item
|
||||
actions.play(std::string("first argument, longer than the small string buffer"));
|
||||
actions.play(std::string("second argument, dropped by the stop"));
|
||||
script.running = false;
|
||||
wait.loop();
|
||||
ASSERT_EQ(restart.seen.size(), 1u);
|
||||
|
||||
script.running = false;
|
||||
wait.loop();
|
||||
ASSERT_EQ(restart.seen.size(), 2u);
|
||||
EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer");
|
||||
}
|
||||
|
||||
TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) {
|
||||
FakeScript script;
|
||||
ScriptWaitAction<FakeScript, const std::string &> wait(&script);
|
||||
RecordAction record;
|
||||
ActionList<const std::string &> actions;
|
||||
actions.add_actions({&wait, &record});
|
||||
|
||||
actions.play(std::string("argument"));
|
||||
script.running = false;
|
||||
wait.loop();
|
||||
EXPECT_EQ(record.seen, "argument");
|
||||
wait.loop();
|
||||
EXPECT_TRUE(wait.is_idle());
|
||||
}
|
||||
|
||||
} // namespace esphome::script::testing
|
||||
|
||||
@@ -6,7 +6,6 @@ stepper:
|
||||
step_pin: ${step_pin}
|
||||
dir_pin: ${dir_pin}
|
||||
enn_pin: ${enn_pin}
|
||||
diag_pin: ${diag_pin}
|
||||
rsense: 110 mOhm
|
||||
vsense: false
|
||||
ottrim: 1
|
||||
@@ -16,17 +15,6 @@ stepper:
|
||||
max_speed: 2000 steps/s
|
||||
acceleration: 1000 steps/s^2
|
||||
deceleration: 1000 steps/s^2
|
||||
on_stall:
|
||||
- stepper.report_position:
|
||||
id: tmc2209_step_dir
|
||||
position: 0
|
||||
- stepper.set_target:
|
||||
id: tmc2209_step_dir
|
||||
target: 0
|
||||
on_status:
|
||||
- logger.log:
|
||||
format: "Driver event %u active %d"
|
||||
args: [event, active]
|
||||
|
||||
- platform: tmc2209
|
||||
id: tmc2209_serial
|
||||
@@ -36,12 +24,6 @@ stepper:
|
||||
clock_frequency: 12MHz
|
||||
analog_current_scale: true
|
||||
max_speed: 1000 steps/s
|
||||
on_status:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return event == tmc22xx::DRIVER_EVENT_OVERTEMPERATURE_PREWARNING && active;"
|
||||
then:
|
||||
- tmc2209.disable: tmc2209_serial
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
@@ -67,17 +49,6 @@ esphome:
|
||||
id: tmc2209_serial
|
||||
irun: 20
|
||||
ihold: !lambda "return 5;"
|
||||
- tmc2209.stallguard:
|
||||
id: tmc2209_step_dir
|
||||
threshold: 50
|
||||
tcool_threshold: !lambda "return 400;"
|
||||
- tmc2209.coolconf:
|
||||
id: tmc2209_step_dir
|
||||
semin: 5
|
||||
semax: 2
|
||||
seup: 1
|
||||
sedn: 0
|
||||
seimin: true
|
||||
- tmc2209.enable: tmc2209_serial
|
||||
- stepper.set_target:
|
||||
id: tmc2209_serial
|
||||
|
||||
@@ -3,7 +3,6 @@ substitutions:
|
||||
dir_pin: GPIO13
|
||||
enn_pin: GPIO14
|
||||
index_pin: GPIO15
|
||||
diag_pin: GPIO4
|
||||
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
|
||||
@@ -3,7 +3,6 @@ substitutions:
|
||||
dir_pin: GPIO13
|
||||
enn_pin: GPIO14
|
||||
index_pin: GPIO15
|
||||
diag_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
|
||||
|
||||
@@ -3,7 +3,6 @@ substitutions:
|
||||
dir_pin: GPIO3
|
||||
enn_pin: GPIO6
|
||||
index_pin: GPIO7
|
||||
diag_pin: GPIO8
|
||||
|
||||
packages:
|
||||
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
|
||||
|
||||
@@ -55,7 +55,6 @@ voice_assistant:
|
||||
speaker: va_speaker_id
|
||||
micro_wake_word: mww_id
|
||||
conversation_timeout: 60s
|
||||
tts_playback_start_timeout: 10s
|
||||
on_listening:
|
||||
- logger.log: "Voice assistant microphone listening"
|
||||
on_start:
|
||||
|
||||
@@ -36,7 +36,6 @@ voice_assistant:
|
||||
channels: 0
|
||||
speaker: va_speaker_id
|
||||
conversation_timeout: 60s
|
||||
tts_playback_start_timeout: 10s
|
||||
on_listening:
|
||||
- logger.log: "Voice assistant microphone listening"
|
||||
on_start:
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Tests for the filter_lifetime configuration schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.filter_lifetime import sensor as filter_lifetime
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACCURACY_DECIMALS,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_STATE_CLASS,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEVICE_CLASS_DURATION,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_HOUR,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.core import Lambda
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
def _validate(config: dict[str, Any]) -> ConfigType:
|
||||
return filter_lifetime.CONFIG_SCHEMA({"name": "Filter", **config})
|
||||
|
||||
|
||||
def test_max_lifetime_is_stored_in_minutes() -> None:
|
||||
config = _validate({"max_lifetime": "180d"})
|
||||
assert config["max_lifetime"].total_minutes == 180 * 24 * 60
|
||||
|
||||
|
||||
def test_max_lifetime_is_required() -> None:
|
||||
with pytest.raises(cv.Invalid, match="required key not provided"):
|
||||
_validate({})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0min", "30s", 6])
|
||||
def test_max_lifetime_rejects_values_under_one_minute(value: Any) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
_validate({"max_lifetime": value})
|
||||
|
||||
|
||||
def test_defaults() -> None:
|
||||
config = _validate({"max_lifetime": "180d"})
|
||||
assert config[CONF_UPDATE_INTERVAL].total_seconds == 60
|
||||
assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_PERCENT
|
||||
assert config[CONF_ACCURACY_DECIMALS] == 2
|
||||
assert "is_on" not in config
|
||||
assert "current_speed" not in config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lambda_key", "sensor_key"),
|
||||
[("is_on", "is_on_sensor"), ("current_speed", "current_speed_sensor")],
|
||||
)
|
||||
def test_lambda_and_sensor_are_mutually_exclusive(
|
||||
lambda_key: str, sensor_key: str
|
||||
) -> None:
|
||||
with pytest.raises(cv.Invalid, match="Cannot specify more than one of"):
|
||||
_validate(
|
||||
{
|
||||
"max_lifetime": "180d",
|
||||
lambda_key: Lambda("return 1;"),
|
||||
sensor_key: "some_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_lambda_inputs_accepted() -> None:
|
||||
config = _validate(
|
||||
{
|
||||
"max_lifetime": "180d",
|
||||
"is_on": Lambda("return true;"),
|
||||
"current_speed": Lambda("return 50.0f;"),
|
||||
}
|
||||
)
|
||||
assert config["is_on"].value == "return true;"
|
||||
assert config["current_speed"].value == "return 50.0f;"
|
||||
|
||||
|
||||
def test_sub_sensor_defaults() -> None:
|
||||
config = _validate(
|
||||
{
|
||||
"max_lifetime": "180d",
|
||||
"runtime_hours": {"name": "Runtime"},
|
||||
"remaining_days": {"name": "Remaining"},
|
||||
}
|
||||
)
|
||||
runtime = config["runtime_hours"]
|
||||
assert runtime[CONF_UNIT_OF_MEASUREMENT] == UNIT_HOUR
|
||||
assert runtime[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION
|
||||
assert runtime[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING
|
||||
remaining = config["remaining_days"]
|
||||
assert remaining[CONF_UNIT_OF_MEASUREMENT] == filter_lifetime.UNIT_DAY
|
||||
assert remaining[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION
|
||||
assert remaining[CONF_ACCURACY_DECIMALS] == 0
|
||||
@@ -1,35 +0,0 @@
|
||||
"""PCF8574 reset configuration and code generation."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import pcf8574
|
||||
from esphome.const import CONF_ID, CONF_RESET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"options, expected",
|
||||
[
|
||||
({}, True),
|
||||
({"reset": True}, True),
|
||||
({"reset": False}, False),
|
||||
],
|
||||
)
|
||||
async def test_reset_configuration(options: dict, expected: bool) -> None:
|
||||
config = pcf8574.CONFIG_SCHEMA({CONF_ID: "hub", **options})
|
||||
assert config[CONF_RESET] is expected
|
||||
var = Mock()
|
||||
with (
|
||||
patch.object(pcf8574.cg, "new_Pvariable", return_value=var),
|
||||
patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock),
|
||||
patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock),
|
||||
patch.object(pcf8574.cg, "add"),
|
||||
):
|
||||
await pcf8574.to_code(config)
|
||||
|
||||
if expected:
|
||||
var.set_reset.assert_not_called()
|
||||
else:
|
||||
var.set_reset.assert_called_once_with(False)
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Validate Voice Assistant playback timeout configuration."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import voice_assistant
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["0s", "0ms", {"seconds": 0}, "-1s", "500us", "1ns", "500ns", {"nanoseconds": 1}],
|
||||
)
|
||||
def test_playback_timeout_rejects_invalid_duration(value: str | dict[str, int]) -> None:
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
with pytest.raises(cv.Invalid):
|
||||
voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, milliseconds", [("1ms", 1), ("2s", 2000), ("10s", 10000)]
|
||||
)
|
||||
def test_playback_timeout_accepts_positive_milliseconds(
|
||||
value: str, milliseconds: int
|
||||
) -> None:
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value})
|
||||
assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds
|
||||
|
||||
|
||||
def test_playback_timeout_default_is_two_seconds() -> None:
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"}
|
||||
config = voice_assistant.CONFIG_SCHEMA({})
|
||||
assert config["tts_playback_start_timeout"].total_milliseconds == 2000
|
||||
@@ -13,13 +13,9 @@ from esphome.automation import (
|
||||
ApplyCondition,
|
||||
ApplyField,
|
||||
CallbackAutomation,
|
||||
LambdaAction,
|
||||
RepeatAction,
|
||||
TriggerForwarder,
|
||||
TriggerOnFalseForwarder,
|
||||
TriggerOnTrueForwarder,
|
||||
_shadow_iteration,
|
||||
build_action,
|
||||
build_callback_automation,
|
||||
build_callback_automations,
|
||||
build_parent_callback_automation,
|
||||
@@ -45,13 +41,7 @@ from esphome.automation import (
|
||||
)
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTOMATION_ID,
|
||||
CONF_COUNT,
|
||||
CONF_ID,
|
||||
CONF_THEN,
|
||||
CONF_TYPE_ID,
|
||||
)
|
||||
from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN
|
||||
from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import MockObj, RawExpression
|
||||
from esphome.types import ConfigType
|
||||
@@ -1555,56 +1545,3 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None
|
||||
assert str(mock_cg.add.call_args.args[0]) == (
|
||||
f"parent->add_cb(TriggerForwarder<bool>{{{NEW_OBJ}}})"
|
||||
)
|
||||
|
||||
|
||||
def test_shadow_iteration_leaves_other_names() -> None:
|
||||
args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")]
|
||||
assert _shadow_iteration(args) == args
|
||||
|
||||
|
||||
def test_shadow_iteration_renames_inherited_counter() -> None:
|
||||
assert _shadow_iteration([(cg.uint32, "iteration"), (cg.bool_, "x")]) == [
|
||||
(cg.uint32, "iteration_1"),
|
||||
(cg.bool_, "x"),
|
||||
]
|
||||
|
||||
|
||||
def test_shadow_iteration_picks_first_unused_suffix() -> None:
|
||||
"""A counter keeps its suffix at deeper levels; the new one takes the next free name."""
|
||||
args = [
|
||||
(cg.uint32, "iteration"),
|
||||
(cg.uint32, "iteration_1"),
|
||||
(cg.int_, "iteration_3"),
|
||||
]
|
||||
assert _shadow_iteration(args) == [
|
||||
(cg.uint32, "iteration_2"),
|
||||
(cg.uint32, "iteration_1"),
|
||||
(cg.int_, "iteration_3"),
|
||||
]
|
||||
|
||||
|
||||
def _repeat_conf(id_: str, then: list[ConfigType]) -> ConfigType:
|
||||
return {
|
||||
"repeat": {CONF_COUNT: 2, CONF_THEN: then},
|
||||
CONF_TYPE_ID: ID(id_, is_declaration=True, type=RepeatAction),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_repeat_lambda_parameters_are_distinct() -> None:
|
||||
"""Each repeat level adds its own `iteration`; inherited ones are renamed, not duplicated."""
|
||||
CORE.config = {}
|
||||
log = {
|
||||
"lambda": Lambda('ESP_LOGD("t", "%u", iteration);'),
|
||||
CONF_TYPE_ID: ID("log_action", is_declaration=True, type=LambdaAction),
|
||||
}
|
||||
conf = _repeat_conf(
|
||||
"outer", [_repeat_conf("middle", [_repeat_conf("inner", [log])])]
|
||||
)
|
||||
await build_action(conf, cg.TemplateArguments(cg.uint8), [(cg.uint8, "level")])
|
||||
text = CORE.cpp_main_section
|
||||
assert (
|
||||
"(uint32_t iteration, uint32_t iteration_2, uint32_t iteration_1, uint8_t level)"
|
||||
in text
|
||||
)
|
||||
assert "uint32_t iteration, uint32_t iteration)" not in text
|
||||
|
||||
@@ -18,6 +18,7 @@ from esphome.__main__ import run_esphome
|
||||
from esphome.compiled_config import (
|
||||
_LAMBDA_KEY,
|
||||
compiled_config_path,
|
||||
invalidate_compiled_config,
|
||||
load_compiled_config,
|
||||
save_compiled_config,
|
||||
save_compiled_config_and_sidecar,
|
||||
@@ -937,3 +938,46 @@ def test_load_compiled_config_rejects_wizard_only_sidecar(
|
||||
_set_cache_mtime(cache_path, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is None
|
||||
|
||||
|
||||
def test_invalidate_compiled_config_reports_an_unlink_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A cache that cannot be removed would serve the old key later."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
cache = compiled_config_path("test.yaml")
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text("{}")
|
||||
with (
|
||||
patch("pathlib.Path.unlink", side_effect=OSError("busy")),
|
||||
pytest.raises(EsphomeError, match="validated config cache"),
|
||||
):
|
||||
invalidate_compiled_config()
|
||||
|
||||
|
||||
def test_invalidate_compiled_config_drops_every_cache(tmp_path: Path) -> None:
|
||||
"""Another configuration sharing the edited secrets.yaml or include has
|
||||
a cache the mtime check would keep serving."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
storage = compiled_config_path("test.yaml").parent
|
||||
storage.mkdir(parents=True)
|
||||
for name in ("test.yaml", "other.yaml"):
|
||||
(storage / f"{name}.validated.json").write_text("{}")
|
||||
(storage / "other.json").write_text("{}")
|
||||
invalidate_compiled_config()
|
||||
assert sorted(p.name for p in storage.iterdir()) == ["other.json"]
|
||||
|
||||
|
||||
def test_invalidate_compiled_config_reports_a_storage_dir_it_cannot_list(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
storage = compiled_config_path("test.yaml").parent
|
||||
storage.mkdir(parents=True)
|
||||
with (
|
||||
patch("pathlib.Path.iterdir", side_effect=OSError("denied")),
|
||||
pytest.raises(EsphomeError, match="Could not list"),
|
||||
):
|
||||
invalidate_compiled_config()
|
||||
storage.rmdir()
|
||||
invalidate_compiled_config() # no storage dir yet is nothing to drop
|
||||
|
||||
@@ -3,21 +3,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import yaml_util
|
||||
from esphome.compiled_config import compiled_config_path
|
||||
from esphome.config import do_substitution_pass
|
||||
from esphome.const import CONF_ESPHOME, CONF_NAME
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.yaml_edit import (
|
||||
LineEdit,
|
||||
RestoreError,
|
||||
Snapshot,
|
||||
apply_line_edits,
|
||||
editable_file,
|
||||
field_line_re,
|
||||
line_at,
|
||||
own_documents,
|
||||
read_text,
|
||||
restore_files,
|
||||
rewrite,
|
||||
rewritten_text,
|
||||
source_line,
|
||||
source_of,
|
||||
write_keeping_mode,
|
||||
)
|
||||
@@ -98,13 +109,46 @@ def test_a_comment_needs_whitespace_and_a_scalar_is_not_empty() -> None:
|
||||
assert field_line_re("key", "abc#def").match("key: abc#def") is not None
|
||||
|
||||
|
||||
def test_source_of_is_none_for_a_value_validation_added(tmp_path: Path) -> None:
|
||||
"""Only a key read from a file carries a range, and only a mapping read
|
||||
from a file can place its keys; a mapping built in code cannot."""
|
||||
def test_source_of_is_none_for_a_value_validation_added() -> None:
|
||||
"""Only a key read from a file carries a range."""
|
||||
assert source_of({"name": "kitchen"}, "name") is None
|
||||
|
||||
|
||||
def test_a_mapping_built_in_code_places_only_this_configurations_keys(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""merge_config rebuilds a mapping without a range; a value read for this
|
||||
configuration is still placed, one read from elsewhere is not."""
|
||||
_setup(tmp_path, YAML)
|
||||
loaded_key = next(iter(CORE.raw_config[CONF_ESPHOME]))
|
||||
assert source_of({loaded_key: "kitchen"}, CONF_NAME) is None
|
||||
own_key, own_value = next(iter(CORE.raw_config[CONF_ESPHOME].items()))
|
||||
assert source_of({own_key: own_value}, CONF_NAME) == (CORE.config_path, 1)
|
||||
# Only the value names the line that won; without a range it is refused
|
||||
assert source_of({own_key: "x"}, CONF_NAME) is None
|
||||
(tmp_path / "elsewhere.yaml").write_bytes(YAML.encode())
|
||||
other = yaml_util.load_yaml(tmp_path / "elsewhere.yaml")
|
||||
other_key, other_value = next(iter(other[CONF_ESPHOME].items()))
|
||||
assert source_of({other_key: other_value}, CONF_NAME) is None
|
||||
# A value on a line of its own does not name the key's line
|
||||
_setup(tmp_path, "esphome:\n name:\n kitchen\n")
|
||||
own_key, own_value = next(iter(CORE.raw_config[CONF_ESPHOME].items()))
|
||||
assert source_of({own_key: own_value}, CONF_NAME) is None
|
||||
|
||||
|
||||
def test_own_documents_include_a_file_that_only_gives_substitutions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "subs.yaml").write_bytes(b"substitutions:\n x: y\n")
|
||||
(tmp_path / "sensor.yaml").write_bytes(b"platform: template\nname: x\n")
|
||||
_setup(
|
||||
tmp_path,
|
||||
YAML + "packages:\n subs: !include subs.yaml\nsensor:\n"
|
||||
" - !include sensor.yaml\n",
|
||||
)
|
||||
assert (tmp_path / "sensor.yaml").resolve() in own_documents() # via a list
|
||||
CORE.reset()
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.raw_config = {} # what is left after substitutions are taken out
|
||||
assert (tmp_path / "subs.yaml").resolve() in own_documents()
|
||||
|
||||
|
||||
def test_source_of_is_none_for_a_merged_key(tmp_path: Path) -> None:
|
||||
@@ -152,8 +196,6 @@ def test_write_keeps_the_mode_of_the_file_or_another(tmp_path: Path) -> None:
|
||||
def test_write_failures_say_which_step_and_why(tmp_path: Path) -> None:
|
||||
"""A missing mode source, a write that fails, and a mode that cannot be
|
||||
put back after the write are three different situations."""
|
||||
from unittest.mock import patch
|
||||
|
||||
path = _setup(tmp_path, YAML)
|
||||
with pytest.raises(EsphomeError, match="Could not read the mode of .*gone.yaml"):
|
||||
write_keeping_mode(path, YAML, like=tmp_path / "gone.yaml")
|
||||
@@ -180,3 +222,349 @@ def test_read_text_reports_a_file_it_cannot_decode(tmp_path: Path) -> None:
|
||||
path.write_bytes(b"caf\xe9: 1\n")
|
||||
with pytest.raises(EsphomeError, match="Error reading file"):
|
||||
read_text(path)
|
||||
|
||||
|
||||
def test_source_line_refuses_what_is_not_an_editable_file(tmp_path: Path) -> None:
|
||||
"""A remote package is checked out under the build data and is not the
|
||||
user's; a file outside the configuration directory is not either."""
|
||||
(tmp_path / ".esphome").mkdir()
|
||||
(tmp_path / ".esphome" / "base.yaml").write_bytes(b"name: kitchen\n")
|
||||
_setup(tmp_path, "esphome: !include .esphome/base.yaml\n")
|
||||
with pytest.raises(EsphomeError, match="not an editable file"):
|
||||
source_line(CORE.raw_config[CONF_ESPHOME], CONF_NAME)
|
||||
with pytest.raises(EsphomeError, match="was not read from a file"):
|
||||
source_line({"name": "kitchen"}, CONF_NAME)
|
||||
_setup(tmp_path, YAML)
|
||||
assert source_line(CORE.raw_config[CONF_ESPHOME], CONF_NAME) == (
|
||||
CORE.config_path,
|
||||
1,
|
||||
" name: kitchen # the device",
|
||||
)
|
||||
outside = tmp_path.parent / "outside.yaml"
|
||||
outside.write_bytes(b"")
|
||||
with pytest.raises(EsphomeError, match="not an editable file"):
|
||||
editable_file(outside)
|
||||
(tmp_path / "link.yaml").symlink_to(CORE.config_path)
|
||||
assert editable_file(tmp_path / "link.yaml") == CORE.config_path.resolve()
|
||||
|
||||
|
||||
def test_insert_after_goes_before_the_rewrite_of_the_same_line(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Edits apply from the bottom up so an insertion never moves a later
|
||||
line; the file's own line ending is used, also on a bare last line."""
|
||||
text = YAML.replace("\n", "\r\n").rstrip("\r\n")
|
||||
path = _setup(tmp_path, text)
|
||||
edit = _name_edit("garage")
|
||||
added = LineEdit(path, 4, " ssid: kitchen", " password: x", insert_after=True)
|
||||
more = LineEdit(
|
||||
path, 4, " ssid: kitchen", " fast_connect: true", insert_after=True
|
||||
)
|
||||
assert rewritten_text(read_text(path), [added, more, edit]) == (
|
||||
text.replace("name: kitchen", "name: garage")
|
||||
+ "\r\n password: x\r\n fast_connect: true"
|
||||
)
|
||||
edit.old_line = "changed"
|
||||
with pytest.raises(EsphomeError, match="changed since it was read"):
|
||||
rewritten_text(read_text(path), [added, edit])
|
||||
|
||||
|
||||
def test_own_documents_refuses_an_incomplete_set(tmp_path: Path) -> None:
|
||||
_setup(tmp_path, YAML)
|
||||
found = yaml_util.DiscoveredYamlFiles(load_errors=["sub.yaml: broken"])
|
||||
with (
|
||||
patch("esphome.yaml_util.discover_user_yaml_files", return_value=found),
|
||||
pytest.raises(EsphomeError, match="Could not read every file .*sub.yaml"),
|
||||
):
|
||||
own_documents()
|
||||
|
||||
|
||||
def test_apply_keeps_the_secret_registry(tmp_path: Path) -> None:
|
||||
"""The reload must not clear what later output redacts as !secret."""
|
||||
(tmp_path / "secrets.yaml").write_bytes(b"ssid: hidden\n")
|
||||
path = _setup(tmp_path, YAML.replace("ssid: kitchen", "ssid: !secret ssid"))
|
||||
assert yaml_util.is_secret("hidden") == "ssid"
|
||||
apply_line_edits([_name_edit("garage")])
|
||||
assert yaml_util.is_secret("hidden") == "ssid"
|
||||
assert "!secret ssid" in path.read_text()
|
||||
|
||||
|
||||
def test_apply_writes_reloads_drops_the_cache_and_restores(tmp_path: Path) -> None:
|
||||
path = _setup(tmp_path, YAML)
|
||||
cache = compiled_config_path(CORE.config_filename)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text("{}")
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
originals = apply_line_edits([_name_edit("garage")])
|
||||
assert originals == {
|
||||
path: Snapshot(YAML, YAML.replace("kitchen #", "garage #"), mode)
|
||||
}
|
||||
assert path.read_text() == YAML.replace("kitchen #", "garage #")
|
||||
assert not cache.exists()
|
||||
cache.write_text("{}")
|
||||
restore_files(originals)
|
||||
assert path.read_text() == YAML
|
||||
assert not cache.exists()
|
||||
|
||||
|
||||
def test_apply_reloads_a_symlinked_file_by_the_link(tmp_path: Path) -> None:
|
||||
"""A relative !include inside the file resolves beside the link, as the
|
||||
loader read it, not beside the target."""
|
||||
shared = tmp_path / "shared"
|
||||
shared.mkdir()
|
||||
(shared / "device.yaml").write_bytes(
|
||||
b"name: kitchen\nfriendly_name: !include fn.yaml\n"
|
||||
)
|
||||
(tmp_path / "fn.yaml").write_bytes(b"Kitchen\n") # beside the link only
|
||||
(tmp_path / "device.yaml").symlink_to(shared / "device.yaml")
|
||||
_setup(tmp_path, "esphome: !include device.yaml\n")
|
||||
doc, line_no = source_of(CORE.raw_config[CONF_ESPHOME], CONF_NAME)
|
||||
assert doc == tmp_path / "device.yaml"
|
||||
match = field_line_re(CONF_NAME, "kitchen").match(line_at(doc, line_no))
|
||||
edit = LineEdit(doc, line_no, match.string, rewrite(match, "garage"))
|
||||
originals = apply_line_edits([edit])
|
||||
assert list(originals) == [(shared / "device.yaml").resolve()]
|
||||
assert (shared / "device.yaml").read_text() == (
|
||||
"name: garage\nfriendly_name: !include fn.yaml\n"
|
||||
)
|
||||
|
||||
|
||||
def test_apply_rolls_back_a_rewrite_the_loader_refuses(tmp_path: Path) -> None:
|
||||
path = _setup(tmp_path, YAML)
|
||||
edit = _name_edit("garage")
|
||||
edit.new_line = " name: [unterminated"
|
||||
with pytest.raises(EsphomeError, match="no longer loads"):
|
||||
apply_line_edits([edit])
|
||||
assert path.read_text() == YAML
|
||||
with (
|
||||
patch("esphome.yaml_util.load_yaml", side_effect=KeyboardInterrupt),
|
||||
pytest.raises(KeyboardInterrupt),
|
||||
):
|
||||
apply_line_edits([_name_edit("garage")])
|
||||
assert path.read_text() == YAML
|
||||
|
||||
|
||||
def test_restore_reports_every_file_it_could_not_write(tmp_path: Path) -> None:
|
||||
"""A missing file, a failed write and a cache that cannot be dropped are
|
||||
all reported; a file the user changed meanwhile is left alone."""
|
||||
path = _setup(tmp_path, YAML)
|
||||
good = tmp_path / "a.yaml"
|
||||
(tmp_path / "b.yaml").write_bytes(b"")
|
||||
good.write_bytes(b"")
|
||||
with (
|
||||
patch("esphome.yaml_edit.write_file", side_effect=[EsphomeError("disk"), None]),
|
||||
pytest.raises(EsphomeError, match="Could not restore .*b.yaml: disk"),
|
||||
):
|
||||
restore_files({tmp_path / "b.yaml": Snapshot("x", ""), good: Snapshot("y", "")})
|
||||
with pytest.raises(
|
||||
EsphomeError, match="Could not restore .*gone.yaml: Error reading file"
|
||||
):
|
||||
restore_files({tmp_path / "gone.yaml": Snapshot("x", "x")})
|
||||
with (
|
||||
patch(
|
||||
"esphome.compiled_config.invalidate_compiled_config",
|
||||
side_effect=EsphomeError("busy"),
|
||||
),
|
||||
pytest.raises(
|
||||
EsphomeError, match="Could not restore the validated caches: busy"
|
||||
),
|
||||
):
|
||||
restore_files({path: Snapshot(YAML, YAML)})
|
||||
originals = apply_line_edits([_name_edit("garage")])
|
||||
edited = path.read_text() + "logger:\n"
|
||||
path.write_bytes(edited.encode())
|
||||
with pytest.raises(EsphomeError, match="changed since it was written, left as is"):
|
||||
restore_files(originals)
|
||||
assert path.read_text() == edited
|
||||
|
||||
|
||||
def test_apply_rolls_back_when_the_cache_cannot_be_dropped(tmp_path: Path) -> None:
|
||||
path = _setup(tmp_path, YAML)
|
||||
with (
|
||||
patch(
|
||||
"esphome.compiled_config.invalidate_compiled_config",
|
||||
side_effect=[EsphomeError("busy"), None],
|
||||
),
|
||||
pytest.raises(EsphomeError, match="busy"),
|
||||
):
|
||||
apply_line_edits([_name_edit("garage")])
|
||||
assert path.read_text() == YAML
|
||||
|
||||
|
||||
def test_apply_reports_a_rollback_that_also_failed(tmp_path: Path) -> None:
|
||||
"""The rewrite lands, the reload fails, and the rollback write fails too."""
|
||||
from esphome.yaml_edit import write_file
|
||||
|
||||
_setup(tmp_path, YAML)
|
||||
writes: list[int] = []
|
||||
|
||||
def write_then_fail(*args: Any, **kwargs: Any) -> None:
|
||||
writes.append(1)
|
||||
if len(writes) > 1:
|
||||
raise EsphomeError("disk")
|
||||
write_file(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch("esphome.yaml_util.load_yaml", side_effect=EsphomeError("broken")),
|
||||
patch("esphome.yaml_edit.write_file", side_effect=write_then_fail),
|
||||
pytest.raises(EsphomeError, match="broken; Could not restore .*disk"),
|
||||
):
|
||||
apply_line_edits([_name_edit("garage")])
|
||||
|
||||
|
||||
def test_a_failed_rollback_keeps_an_interrupt_an_interrupt(tmp_path: Path) -> None:
|
||||
_setup(tmp_path, YAML)
|
||||
with (
|
||||
patch("esphome.yaml_util.load_yaml", side_effect=KeyboardInterrupt),
|
||||
patch("pathlib.Path.chmod", side_effect=[None, OSError("disk")]),
|
||||
pytest.raises(KeyboardInterrupt) as info,
|
||||
):
|
||||
apply_line_edits([_name_edit("garage")])
|
||||
assert "disk" in "".join(info.value.__notes__)
|
||||
|
||||
|
||||
def test_rollback_after_a_failed_second_write_leaves_the_untouched_file_alone(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The file that was never written is not reported as changed."""
|
||||
path = _setup(tmp_path, YAML)
|
||||
other = tmp_path / "other.yaml"
|
||||
other.write_bytes(b"x: 1\n")
|
||||
edits = [_name_edit("garage"), LineEdit(other, 0, "x: 1", "x: 2")]
|
||||
with (
|
||||
patch(
|
||||
"esphome.yaml_edit.write_keeping_mode",
|
||||
side_effect=[None, EsphomeError("disk")],
|
||||
),
|
||||
pytest.raises(EsphomeError) as info,
|
||||
):
|
||||
apply_line_edits(edits)
|
||||
assert "changed since it was written" not in str(info.value)
|
||||
assert path.read_text() == YAML and other.read_text() == "x: 1\n"
|
||||
|
||||
|
||||
def test_restore_goes_on_after_an_interrupt(tmp_path: Path) -> None:
|
||||
"""A second Ctrl-C must not leave the other files holding the new value."""
|
||||
path = _setup(tmp_path, YAML)
|
||||
other = tmp_path / "other.yaml"
|
||||
other.write_bytes(b"x: 1\n")
|
||||
calls: list[Path] = []
|
||||
|
||||
def interrupt_at(n: int) -> Any:
|
||||
def write(target: Path, text: str, **kwargs: Any) -> None:
|
||||
calls.append(target)
|
||||
if len(calls) == n:
|
||||
raise KeyboardInterrupt
|
||||
write_keeping_mode(target, text, **kwargs)
|
||||
|
||||
return write
|
||||
|
||||
originals = apply_line_edits(
|
||||
[_name_edit("garage"), LineEdit(other, 0, "x: 1", "x: 2")]
|
||||
)
|
||||
with (
|
||||
patch("esphome.yaml_edit.write_keeping_mode", side_effect=interrupt_at(1)),
|
||||
pytest.raises(KeyboardInterrupt) as info,
|
||||
):
|
||||
restore_files(originals)
|
||||
assert calls == [path, other] and other.read_text() == "x: 1\n"
|
||||
assert f"Could not restore {path}: interrupted" in "".join(info.value.__notes__)
|
||||
# The interrupt stays one inside apply as well
|
||||
_setup(tmp_path, YAML)
|
||||
calls.clear()
|
||||
with (
|
||||
patch("esphome.yaml_util.load_yaml", side_effect=EsphomeError("broken")),
|
||||
patch("esphome.yaml_edit.write_keeping_mode", side_effect=interrupt_at(2)),
|
||||
pytest.raises(KeyboardInterrupt) as info,
|
||||
):
|
||||
apply_line_edits([_name_edit("garage")])
|
||||
assert "Could not restore" in "".join(info.value.__notes__)
|
||||
|
||||
|
||||
def test_restore_finishes_after_any_failure(tmp_path: Path) -> None:
|
||||
"""A raw OSError still leaves the other files put back, and an interrupt
|
||||
while dropping the caches carries the report."""
|
||||
path = _setup(tmp_path, YAML)
|
||||
other = tmp_path / "other.yaml"
|
||||
other.write_bytes(b"x: 1\n")
|
||||
originals = apply_line_edits(
|
||||
[_name_edit("garage"), LineEdit(other, 0, "x: 1", "x: 2")]
|
||||
)
|
||||
writes: list[Path] = []
|
||||
|
||||
def fail_first(target: Path, text: str, **kwargs: Any) -> None:
|
||||
writes.append(target)
|
||||
if len(writes) == 1:
|
||||
raise OSError("odd")
|
||||
write_keeping_mode(target, text, **kwargs)
|
||||
|
||||
with (
|
||||
patch("esphome.yaml_edit.write_keeping_mode", side_effect=fail_first),
|
||||
pytest.raises(RestoreError) as info,
|
||||
):
|
||||
restore_files(originals)
|
||||
assert f"Could not restore {path}: odd" in str(
|
||||
info.value
|
||||
) # a Windows path is no regex
|
||||
assert "garage" in path.read_text() and other.read_text() == "x: 1\n"
|
||||
with (
|
||||
patch(
|
||||
"esphome.compiled_config.invalidate_compiled_config",
|
||||
side_effect=KeyboardInterrupt,
|
||||
),
|
||||
pytest.raises(KeyboardInterrupt) as info,
|
||||
):
|
||||
restore_files(originals)
|
||||
assert path.read_text() == YAML
|
||||
assert "the validated caches: interrupted" in "".join(info.value.__notes__)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="posix file modes")
|
||||
def test_restore_puts_back_the_mode_from_before_the_edit(tmp_path: Path) -> None:
|
||||
"""A write whose chmod failed leaves the file 0600; the restore does not
|
||||
copy that, it has the mode from before."""
|
||||
path = _setup(tmp_path, YAML)
|
||||
path.chmod(0o644)
|
||||
originals = apply_line_edits([_name_edit("garage")])
|
||||
path.chmod(0o600)
|
||||
restore_files(originals)
|
||||
assert path.read_text() == YAML
|
||||
assert path.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_restore_finishes_after_an_exit(tmp_path: Path) -> None:
|
||||
"""A SystemExit from a signal handler still leaves the other files put
|
||||
back and comes out again with the report."""
|
||||
path = _setup(tmp_path, YAML)
|
||||
other = tmp_path / "other.yaml"
|
||||
other.write_bytes(b"x: 1\n")
|
||||
originals = apply_line_edits(
|
||||
[_name_edit("garage"), LineEdit(other, 0, "x: 1", "x: 2")]
|
||||
)
|
||||
writes: list[Path] = []
|
||||
|
||||
def exit_first(target: Path, text: str, **kwargs: Any) -> None:
|
||||
writes.append(target)
|
||||
if len(writes) == 1:
|
||||
raise SystemExit(1)
|
||||
write_keeping_mode(target, text, **kwargs)
|
||||
|
||||
with (
|
||||
patch("esphome.yaml_edit.write_keeping_mode", side_effect=exit_first),
|
||||
pytest.raises(SystemExit) as info,
|
||||
):
|
||||
restore_files(originals)
|
||||
assert other.read_text() == "x: 1\n"
|
||||
assert f"Could not restore {path}: SystemExit" in "".join(info.value.__notes__)
|
||||
|
||||
|
||||
def test_a_value_written_to_the_secrets_file_is_registered(tmp_path: Path) -> None:
|
||||
"""Only a resolved !secret registers its value, so the main file is
|
||||
loaded again after an edit elsewhere; own_documents keeps the registry."""
|
||||
secrets = tmp_path / "secrets.yaml"
|
||||
secrets.write_bytes(b"ssid: hidden\n")
|
||||
_setup(tmp_path, YAML.replace("ssid: kitchen", "ssid: !secret ssid"))
|
||||
own_documents()
|
||||
assert yaml_util.is_secret("hidden") == "ssid"
|
||||
apply_line_edits([LineEdit(secrets, 0, "ssid: hidden", "ssid: hidden2")])
|
||||
assert yaml_util.is_secret("hidden2") == "ssid"
|
||||
|
||||
Reference in New Issue
Block a user