mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 00:12:11 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caec9c1f06 | ||
|
|
e1470bf247 | ||
|
|
32ce02d77b | ||
|
|
b225b9b332 | ||
|
|
bd723f75af | ||
|
|
efba19ea3a | ||
|
|
6c68985c14 | ||
|
|
7be8a7c390 | ||
|
|
eb8cf139d7 | ||
|
|
ab6ebac628 | ||
|
|
6cb622417a | ||
|
|
960316abd6 | ||
|
|
87341dc136 |
No files matched your search
@@ -22,7 +22,7 @@ CONF_CUSTOM_PATTERNS = "custom_patterns"
|
||||
CONF_SKIP_CRC = "skip_crc"
|
||||
CONF_DEFAULT_OBIS = "default_obis"
|
||||
|
||||
DLMS_PARSER_VERSION = "2.2.0"
|
||||
DLMS_PARSER_VERSION = "2.2.2"
|
||||
|
||||
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
|
||||
DlmsMeterComponent = dlms_meter_component_ns.class_(
|
||||
|
||||
@@ -2389,7 +2389,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Optional(CONF_SOURCE): cv.git_ref,
|
||||
cv.Optional(CONF_SOURCE): cv.All(
|
||||
cv.string_strict, cv.Length(min=1)
|
||||
),
|
||||
cv.Optional(CONF_REF): cv.string,
|
||||
cv.Optional(CONF_PATH): cv.string,
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ static uint8_t calc_checksum(const void *data, size_t size) {
|
||||
return xor_bytes(static_cast<const uint8_t *>(data), size);
|
||||
}
|
||||
|
||||
// Simple mode needs simple reporting from the module; Normal and Calibrate read energy frames
|
||||
static uint16_t system_mode_for(uint8_t mode) {
|
||||
return mode == OP_SIMPLE_MODE ? CMD_SYSTEM_MODE_SIMPLE : CMD_SYSTEM_MODE_ENERGY;
|
||||
}
|
||||
|
||||
static int32_t get_firmware_int(const char *version_string) {
|
||||
// Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits
|
||||
const char *p = (*version_string == 'v') ? version_string + 1 : version_string;
|
||||
@@ -207,13 +212,10 @@ void LD2420Component::setup() {
|
||||
|
||||
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
this->set_operating_mode(OP_SIMPLE_MODE);
|
||||
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
|
||||
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
|
||||
} else {
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
|
||||
}
|
||||
this->set_operating_mode(OP_NORMAL_MODE); // older firmware is held in Simple
|
||||
this->set_mode_(system_mode_for(this->current_operating_mode));
|
||||
#ifdef USE_NUMBER
|
||||
this->init_gate_config_numbers();
|
||||
#endif
|
||||
@@ -244,7 +246,9 @@ void LD2420Component::apply_config_action() {
|
||||
#endif
|
||||
this->set_system_mode(this->system_mode_);
|
||||
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
// Applying the calibrated thresholds ends calibration; Normal and Simple stay as they are
|
||||
if (this->current_operating_mode == OP_CALIBRATE_MODE)
|
||||
this->set_operating_mode(OP_NORMAL_MODE);
|
||||
}
|
||||
|
||||
void LD2420Component::factory_reset_action() {
|
||||
@@ -356,6 +360,22 @@ void LD2420Component::set_operating_mode(const char *state) {
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::change_operating_mode(OpMode mode) {
|
||||
this->set_operating_mode(mode);
|
||||
const uint16_t system_mode = system_mode_for(this->current_operating_mode);
|
||||
if (system_mode == this->get_mode_())
|
||||
return;
|
||||
if (this->set_config_mode(true) != LD2420_ERROR_TIMEOUT) {
|
||||
this->set_system_mode(system_mode);
|
||||
this->set_config_mode(false);
|
||||
}
|
||||
if (this->get_mode_() != system_mode) {
|
||||
// The module kept its reporting mode; show the mode that matches it
|
||||
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
|
||||
this->set_operating_mode(this->get_mode_() == CMD_SYSTEM_MODE_SIMPLE ? OP_SIMPLE_MODE : OP_NORMAL_MODE);
|
||||
}
|
||||
}
|
||||
|
||||
void LD2420Component::set_operating_mode(OpMode mode) {
|
||||
// Older firmware only supports simple mode
|
||||
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
|
||||
|
||||
@@ -119,6 +119,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
|
||||
void handle_cmd_error(uint16_t error);
|
||||
void set_operating_mode(const char *state);
|
||||
void set_operating_mode(OpMode mode);
|
||||
void change_operating_mode(OpMode mode);
|
||||
void auto_calibrate_sensitivity();
|
||||
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
|
||||
uint8_t set_config_mode(bool enable);
|
||||
|
||||
@@ -30,6 +30,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
CONF_OPERATING_MODE,
|
||||
hub.set_operating_mode_select,
|
||||
options=CONF_SELECTS,
|
||||
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
publish="before",
|
||||
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
|
||||
)
|
||||
@@ -607,9 +607,10 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
# esp_rom_printf instead of pulling ~1.2KB esp_rom_vprintf into IRAM.
|
||||
# S2/C2 have a ROM vprintf, so IDF forces CONSTRAINED_ENV_SAFE=1 and
|
||||
# cache-off DRAM logs would reach the flash-resident wrapper; keep V1.
|
||||
if idf_version() >= cv.Version(6, 1, 0) and get_esp32_variant() not in (
|
||||
VARIANT_ESP32C2,
|
||||
VARIANT_ESP32S2,
|
||||
if (
|
||||
CORE.using_toolchain_esp_idf
|
||||
and idf_version() >= cv.Version(6, 1, 0)
|
||||
and get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32S2)
|
||||
):
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_1", False)
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_VERSION_2", True)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
@@ -53,16 +54,25 @@ using DataCallbackManagerBase = CallbackManager<void(const AudioCapture &)>;
|
||||
|
||||
class DataCallbackManager : public DataCallbackManagerBase {
|
||||
public:
|
||||
explicit DataCallbackManager(const audio::AudioStreamInfo *stream_info) : stream_info_(stream_info) {}
|
||||
DataCallbackManager(const audio::AudioStreamInfo *stream_info, const bool *mute_state)
|
||||
: stream_info_(stream_info), mute_state_(mute_state) {}
|
||||
|
||||
using DataCallbackManagerBase::call;
|
||||
|
||||
// Remove before 2027.5.0, along with this class (use a plain CallbackManager)
|
||||
ESPDEPRECATED("Use Microphone::publish_audio_() instead. Removed in 2027.5.0", "2026.11.0")
|
||||
void call(const std::vector<uint8_t> &data) { this->call(AudioCapture{data, *this->stream_info_, 0}); }
|
||||
void call(const std::vector<uint8_t> &data) {
|
||||
if (*this->mute_state_) {
|
||||
const std::vector<uint8_t> zeros(data.size(), 0);
|
||||
this->call(AudioCapture{zeros, *this->stream_info_, 0});
|
||||
} else {
|
||||
this->call(AudioCapture{data, *this->stream_info_, 0});
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
const audio::AudioStreamInfo *stream_info_;
|
||||
const bool *mute_state_;
|
||||
};
|
||||
|
||||
class Microphone {
|
||||
@@ -72,15 +82,7 @@ class Microphone {
|
||||
|
||||
/// Callbacks take (const AudioCapture &) and run on the microphone's task.
|
||||
template<typename F> void add_data_callback(F &&data_callback) {
|
||||
this->data_callbacks_.add(
|
||||
[this, callback = as_capture_callback(std::forward<F>(data_callback))](const AudioCapture &capture) {
|
||||
if (this->mute_state_) {
|
||||
const std::vector<uint8_t> zeros(capture.data.size(), 0);
|
||||
callback(AudioCapture{zeros, capture.stream_info, capture.capture_time_us});
|
||||
} else {
|
||||
callback(capture);
|
||||
}
|
||||
});
|
||||
this->data_callbacks_.add(as_capture_callback(std::forward<F>(data_callback)));
|
||||
}
|
||||
|
||||
bool is_running() const { return this->state_ == STATE_RUNNING; }
|
||||
@@ -92,8 +94,11 @@ class Microphone {
|
||||
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
|
||||
|
||||
protected:
|
||||
void publish_audio_(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info,
|
||||
int64_t capture_time_us = 0) {
|
||||
/// While muted, data is zeroed first, so it must be a buffer the platform owns.
|
||||
void publish_audio_(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us = 0) {
|
||||
if (this->mute_state_) {
|
||||
std::memset(data.data(), 0, data.size());
|
||||
}
|
||||
this->data_callbacks_.call(AudioCapture{data, stream_info, capture_time_us});
|
||||
}
|
||||
|
||||
@@ -105,7 +110,7 @@ class Microphone {
|
||||
audio::AudioStreamInfo audio_stream_info_;
|
||||
|
||||
// Make private when the deprecated call(data) is removed in 2027.5.0
|
||||
DataCallbackManager data_callbacks_{&this->audio_stream_info_};
|
||||
DataCallbackManager data_callbacks_{&this->audio_stream_info_, &this->mute_state_};
|
||||
};
|
||||
|
||||
} // namespace esphome::microphone
|
||||
@@ -12,7 +12,7 @@ void PipsolarOutput::write_state(float state) {
|
||||
|
||||
if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) {
|
||||
ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state);
|
||||
this->parent_->queue_command(std::string(tmp));
|
||||
this->parent_->queue_command(tmp);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
|
||||
}
|
||||
|
||||
@@ -224,17 +224,23 @@ bool Pipsolar::send_next_poll_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
void Pipsolar::queue_command(const std::string &command) {
|
||||
void Pipsolar::write_switch(bool state, const char *on, const char *off) {
|
||||
const char *command = state ? on : off;
|
||||
if (command != nullptr)
|
||||
this->queue_command(command);
|
||||
}
|
||||
|
||||
void Pipsolar::queue_command(const char *command) {
|
||||
uint8_t next_position = command_queue_position_;
|
||||
for (uint8_t i = 0; i < COMMAND_QUEUE_LENGTH; i++) {
|
||||
uint8_t testposition = (next_position + i) % COMMAND_QUEUE_LENGTH;
|
||||
if (command_queue_[testposition].empty()) {
|
||||
command_queue_[testposition] = command;
|
||||
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command.c_str(), testposition);
|
||||
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command, testposition);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str());
|
||||
ESP_LOGD(TAG, "Command queue full dropping command: %s", command);
|
||||
}
|
||||
|
||||
void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) {
|
||||
|
||||
@@ -194,7 +194,10 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent {
|
||||
PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI)
|
||||
PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI)
|
||||
|
||||
void queue_command(const std::string &command);
|
||||
void queue_command(const char *command);
|
||||
void queue_command(const std::string &command) { this->queue_command(command.c_str()); }
|
||||
// A switch write; off is nullptr when the inverter has no off command
|
||||
void write_switch(bool state, const char *on, const char *off);
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ICON_POWER
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns
|
||||
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
@@ -16,21 +20,55 @@ CONF_INPUT_VOLTAGE_RANGE = "input_voltage_range"
|
||||
CONF_PV_OK_CONDITION_FOR_PARALLEL = "pv_ok_condition_for_parallel"
|
||||
CONF_PV_POWER_BALANCE = "pv_power_balance"
|
||||
|
||||
|
||||
class _PipSwitch(NamedTuple):
|
||||
setter: Callable[[MockObj], MockObj]
|
||||
on: str
|
||||
off: str | None # None when the inverter has no off command
|
||||
|
||||
|
||||
# Each switch's hub setter and its on and off commands
|
||||
TYPES = {
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: ("POP00", None),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: ("POP01", None),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: ("POP02", None),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: ("POP03", None),
|
||||
CONF_INPUT_VOLTAGE_RANGE: ("PGR01", "PGR00"),
|
||||
CONF_PV_OK_CONDITION_FOR_PARALLEL: ("PPVOKC1", "PPVOKC0"),
|
||||
CONF_PV_POWER_BALANCE: ("PSPB1", "PSPB0"),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_utility_switch,
|
||||
"POP00",
|
||||
None,
|
||||
),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_solar_switch,
|
||||
"POP01",
|
||||
None,
|
||||
),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_battery_switch,
|
||||
"POP02",
|
||||
None,
|
||||
),
|
||||
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: _PipSwitch(
|
||||
lambda hub: hub.set_output_source_priority_hybrid_switch,
|
||||
"POP03",
|
||||
None,
|
||||
),
|
||||
CONF_INPUT_VOLTAGE_RANGE: _PipSwitch(
|
||||
lambda hub: hub.set_input_voltage_range_switch,
|
||||
"PGR01",
|
||||
"PGR00",
|
||||
),
|
||||
CONF_PV_OK_CONDITION_FOR_PARALLEL: _PipSwitch(
|
||||
lambda hub: hub.set_pv_ok_condition_for_parallel_switch,
|
||||
"PPVOKC1",
|
||||
"PPVOKC0",
|
||||
),
|
||||
CONF_PV_POWER_BALANCE: _PipSwitch(
|
||||
lambda hub: hub.set_pv_power_balance_switch,
|
||||
"PSPB1",
|
||||
"PSPB0",
|
||||
),
|
||||
}
|
||||
|
||||
PipsolarSwitch = pipsolar_ns.class_("PipsolarSwitch", switch.Switch, cg.Component)
|
||||
|
||||
PIPSWITCH_SCHEMA = switch.switch_schema(
|
||||
PipsolarSwitch, icon=ICON_POWER, block_inverted=True
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
switch.ApplySwitch, icon=ICON_POWER, block_inverted=True
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
|
||||
{cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES}
|
||||
@@ -38,15 +76,11 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
paren = await cg.get_variable(config[CONF_PIPSOLAR_ID])
|
||||
|
||||
for type, (on, off) in TYPES.items():
|
||||
if type in config:
|
||||
conf = config[type]
|
||||
var = await switch.new_switch(conf)
|
||||
await cg.register_component(var, conf)
|
||||
cg.add(getattr(paren, f"set_{type}_switch")(var))
|
||||
cg.add(var.set_parent(paren))
|
||||
cg.add(var.set_on_command(on))
|
||||
if off is not None:
|
||||
cg.add(var.set_off_command(off))
|
||||
hub = await cg.get_variable(config[CONF_PIPSOLAR_ID])
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
# The hub polls the inverter and publishes each switch's state
|
||||
for key, sw in TYPES.items():
|
||||
off_arg = "nullptr" if sw.off is None else f'"{sw.off}"'
|
||||
await switches(
|
||||
key, sw.setter(hub), apply=f'write_switch(state, "{sw.on}", {off_arg})'
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
#include "pipsolar_switch.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::pipsolar {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "pipsolar.switch");
|
||||
|
||||
void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); }
|
||||
void PipsolarSwitch::write_state(bool state) {
|
||||
const char *command = state ? this->on_command_ : this->off_command_;
|
||||
if (command != nullptr) {
|
||||
this->parent_->queue_command(command);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::pipsolar
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "../pipsolar.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::pipsolar {
|
||||
class Pipsolar;
|
||||
class PipsolarSwitch final : public switch_::Switch, public Component {
|
||||
public:
|
||||
void set_parent(Pipsolar *parent) { this->parent_ = parent; }
|
||||
void set_on_command(const char *command) { this->on_command_ = command; }
|
||||
void set_off_command(const char *command) { this->off_command_ = command; }
|
||||
/// Prevent accidental use of std::string which would dangle
|
||||
void set_on_command(const std::string &command) = delete;
|
||||
void set_off_command(const std::string &command) = delete;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override;
|
||||
const char *on_command_{nullptr};
|
||||
const char *off_command_{nullptr};
|
||||
Pipsolar *parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::pipsolar
|
||||
@@ -90,6 +90,7 @@ bool ResamplerMicrophone::init_resampler_(const audio::AudioStreamInfo &input_st
|
||||
|
||||
if (input_stream_info.get_sample_rate() == this->target_sample_rate_) {
|
||||
// The source already delivers the target sample rate, so its audio is passed through unchanged
|
||||
this->output_buffer_.reserve(input_stream_info.ms_to_bytes(BUFFER_DURATION_MS));
|
||||
this->input_stream_info_ = input_stream_info;
|
||||
this->resampler_ready_ = true;
|
||||
return true;
|
||||
@@ -137,7 +138,23 @@ void ResamplerMicrophone::process_audio_(const microphone::AudioCapture &capture
|
||||
}
|
||||
|
||||
if (this->resampler_ == nullptr) {
|
||||
this->publish_audio_(capture.data, input_stream_info, capture.capture_time_us);
|
||||
if (this->mute_state_) {
|
||||
// The source's buffer is shared with its other consumers, so publish zeros from our own buffer, in pieces that
|
||||
// fit its reserved size so muting never allocates
|
||||
const uint32_t max_frames = input_stream_info.ms_to_frames(BUFFER_DURATION_MS);
|
||||
const uint32_t total_frames = input_stream_info.bytes_to_frames(capture.data.size());
|
||||
for (uint32_t done = 0; done < total_frames;) {
|
||||
const uint32_t frames = std::min(total_frames - done, max_frames);
|
||||
this->output_buffer_.assign(input_stream_info.frames_to_bytes(frames), 0);
|
||||
const int64_t time_us =
|
||||
capture.capture_time_us == 0 ? 0 : capture.capture_time_us + input_stream_info.frames_to_microseconds(done);
|
||||
this->publish_audio_(this->output_buffer_, input_stream_info, time_us);
|
||||
done += frames;
|
||||
}
|
||||
} else {
|
||||
// Needs a protected forwarding method once data_callbacks_ is made private in 2027.5.0
|
||||
this->data_callbacks_.call(microphone::AudioCapture{capture.data, input_stream_info, capture.capture_time_us});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components.audio_dac import AudioDac
|
||||
from esphome.components.const import CONF_MODULATION
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL
|
||||
from esphome.cpp_generator import MockObj
|
||||
@@ -19,6 +20,7 @@ DEPENDENCIES = ["i2c"]
|
||||
|
||||
CONF_ANALOG_GAIN = "analog_gain"
|
||||
CONF_DAC_MODE = "dac_mode"
|
||||
CONF_FAULT_OUTPUT = "fault_output"
|
||||
CONF_IGNORE_ENABLE_PIN_WARNING = "ignore_enable_pin_warning"
|
||||
CONF_MIXER_MODE = "mixer_mode"
|
||||
CONF_VOLUME_MIN_DB = "volume_min_db"
|
||||
@@ -39,6 +41,11 @@ MIXER_MODES = MixerMode.options(
|
||||
"stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_"
|
||||
)
|
||||
|
||||
Modulation = tas58xx_ns.enum("Modulation")
|
||||
MODULATIONS = Modulation.options("bd_mode", "1spw_mode", prefix="MODULATION_")
|
||||
|
||||
FAULT_OUTPUT_NONE = "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Model:
|
||||
@@ -49,6 +56,9 @@ class Model:
|
||||
volume_min_db: float
|
||||
volume_max_db: float
|
||||
default_address: int
|
||||
# Pins that can report FAULTZ: name -> pin number n, selected at 0x61 + n (ModelInfo::fault_output_pins)
|
||||
fault_outputs: dict[str, int]
|
||||
default_fault_output: str
|
||||
|
||||
|
||||
MODELS: dict[str, Model] = {
|
||||
@@ -58,6 +68,8 @@ MODELS: dict[str, Model] = {
|
||||
volume_min_db=-103.0,
|
||||
volume_max_db=24.0,
|
||||
default_address=0x2D,
|
||||
fault_outputs={"adr": 0},
|
||||
default_fault_output="adr",
|
||||
),
|
||||
DAC_TAS5825M: Model(
|
||||
model_info=tas58xx_ns.TAS5825M_MODEL,
|
||||
@@ -65,6 +77,8 @@ MODELS: dict[str, Model] = {
|
||||
volume_min_db=-103.0,
|
||||
volume_max_db=24.0,
|
||||
default_address=0x4C,
|
||||
fault_outputs={"gpio0": 0, "gpio1": 1, "gpio2": 2},
|
||||
default_fault_output="gpio1",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -100,9 +114,15 @@ def _model_schema(model: Model) -> cv.Schema:
|
||||
cv.Optional(CONF_DAC_MODE, default="btl"): cv.enum(
|
||||
DAC_MODES, lower=True
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_FAULT_OUTPUT, default=model.default_fault_output
|
||||
): cv.one_of(FAULT_OUTPUT_NONE, *model.fault_outputs, lower=True),
|
||||
cv.Optional(CONF_MIXER_MODE, default="stereo"): cv.enum(
|
||||
MIXER_MODES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_MODULATION, default="bd_mode"): cv.enum(
|
||||
MODULATIONS, lower=True
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_VOLUME_MIN_DB, default=model.volume_min_db
|
||||
): volume_validator,
|
||||
@@ -172,7 +192,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN]))
|
||||
cg.add(var.set_dac_mode(config[CONF_DAC_MODE]))
|
||||
if (fault_output := config[CONF_FAULT_OUTPUT]) != FAULT_OUTPUT_NONE:
|
||||
cg.add(var.set_fault_output(model.fault_outputs[fault_output]))
|
||||
cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE]))
|
||||
cg.add(var.set_modulation(config[CONF_MODULATION]))
|
||||
cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB]))
|
||||
cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB]))
|
||||
await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN))
|
||||
@@ -8,11 +8,8 @@ namespace esphome::tas58xx {
|
||||
// Registers 0x46, 0x7D, 0x7E and page 1 register 0x51 are not documented in the datasheet.
|
||||
static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep
|
||||
{0x46, 0x01},
|
||||
{0x46, 0x01}, // PPC 96 kHz process flow value (its 48 kHz FIR flows write 0x11)
|
||||
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
|
||||
// The I2C address is latched at power up, after which the ADR pin can report faults
|
||||
{0x61, 0x0B}, // ADR_PIN_CONFIG: FAULTZ
|
||||
{0x60, 0x01}, // ADR_PIN_CTRL: output
|
||||
{0x7D, 0x11},
|
||||
{0x7E, 0xFF},
|
||||
{0x00, 0x01}, // Page 1
|
||||
@@ -25,6 +22,8 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
|
||||
static const LogString *model_name() { return LOG_STR("TAS5805M"); }
|
||||
|
||||
static const LogString *fault_output_name(uint8_t) { return LOG_STR("ADR"); }
|
||||
|
||||
// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266.
|
||||
static const LogString *fault_name(uint8_t index) {
|
||||
if (index == 0)
|
||||
@@ -88,6 +87,8 @@ const ModelInfo TAS5805M_MODEL = {
|
||||
NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING
|
||||
NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING
|
||||
},
|
||||
.fault_output_pins = 1, // ADR
|
||||
.fault_output_name = fault_output_name,
|
||||
};
|
||||
|
||||
} // namespace esphome::tas58xx
|
||||
@@ -6,7 +6,7 @@ namespace esphome::tas58xx {
|
||||
|
||||
// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page.
|
||||
// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k".
|
||||
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
|
||||
// Registers 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
|
||||
static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from
|
||||
{0x7D, 0x11},
|
||||
@@ -17,20 +17,25 @@ static constexpr i2c::I2CRegValue STARTUP_SEQUENCE[] PROGMEM = {
|
||||
{0x1D, 0x00},
|
||||
{0x19, 0x80},
|
||||
{0x00, 0x00}, // Page 0
|
||||
{0x46, 0x11},
|
||||
{0x46, 0x11}, // DSP_CTRL: 96 kHz processing (bits 4-3 = 10), use default coefficients (bit 0 = 1)
|
||||
// PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML
|
||||
{0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz
|
||||
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
|
||||
|
||||
// Additional register configuration
|
||||
// GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ
|
||||
{0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ
|
||||
{0x60, 0x02}, // GPIO_CTRL: GPIO1 is output
|
||||
{0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults
|
||||
};
|
||||
|
||||
static const LogString *model_name() { return LOG_STR("TAS5825M"); }
|
||||
|
||||
static const LogString *fault_output_name(uint8_t pin) {
|
||||
if (pin == 0)
|
||||
return LOG_STR("GPIO0");
|
||||
if (pin == 1)
|
||||
return LOG_STR("GPIO1");
|
||||
return LOG_STR("GPIO2");
|
||||
}
|
||||
|
||||
// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266.
|
||||
static const LogString *fault_name(uint8_t index) {
|
||||
if (index == 0)
|
||||
@@ -113,6 +118,8 @@ const ModelInfo TAS5825M_MODEL = {
|
||||
// 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included
|
||||
// 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included
|
||||
},
|
||||
.fault_output_pins = 3, // GPIO0, GPIO1, GPIO2 (ADR is an analog input only on TAS5825M)
|
||||
.fault_output_name = fault_output_name,
|
||||
};
|
||||
|
||||
} // namespace esphome::tas58xx
|
||||
@@ -20,6 +20,7 @@ static constexpr uint8_t TAS58XX_RESET_CTRL = 0x01;
|
||||
static constexpr uint8_t TAS58XX_RESET_CTRL_ALL = 0x11; // Reset DSP/control port and registers
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1 = 0x02;
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_PBTL = (1 << 2);
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_1SPW = 0x01; // DAMP_MOD = 01
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_2 = 0x03;
|
||||
static constexpr uint8_t TAS58XX_DEVICE_CTRL_2_MUTE = (1 << 3);
|
||||
static constexpr uint8_t TAS58XX_CTRL_STATE_MASK = 0x03; // DEVICE_CTRL_2 and POWER_STATE
|
||||
@@ -33,6 +34,9 @@ static constexpr uint8_t TAS58XX_DIG_VOL_0DB = 0x30;
|
||||
static constexpr uint8_t TAS58XX_DIG_VOL_MINUS_103DB = 0xFE;
|
||||
static constexpr uint8_t TAS58XX_AGAIN = 0x54; // 0x00 = 0 dB to 0x1F = -15.5 dB in 0.5 dB steps
|
||||
static constexpr uint8_t TAS58XX_AGAIN_MINUS_15_5DB = 0x1F;
|
||||
static constexpr uint8_t TAS58XX_GPIO_CTRL = 0x60; // ADR_PIN_CTRL on TAS5805M: output enable, bit n for pin n
|
||||
static constexpr uint8_t TAS58XX_GPIO0_SEL = 0x61; // ADR_PIN_CONFIG on TAS5805M: pin n is selected at 0x61 + n
|
||||
static constexpr uint8_t TAS58XX_GPIO_SEL_FAULTZ = 0x0B; // pin function: FAULTZ output
|
||||
static constexpr uint8_t TAS58XX_POWER_STATE = 0x68;
|
||||
static constexpr uint8_t TAS58XX_CHAN_FAULT = 0x70; // GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING follow
|
||||
static constexpr uint8_t TAS58XX_FAULT_REGISTER_COUNT = 4;
|
||||
@@ -98,12 +102,26 @@ bool TAS58xx::init_() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Setters are public, so keep the value inside the register range
|
||||
// The I2C address is latched at power up, so ADR can now be an output. The pin function is written before the
|
||||
// output enable, so the pin never drives another function.
|
||||
if (this->fault_output_pin_ != FAULT_OUTPUT_NONE) {
|
||||
// The setter is public, so only write this model's pins; YAML only allows the pins of the model
|
||||
if (this->fault_output_pin_ >= this->model_->fault_output_pins) {
|
||||
ESP_LOGW(TAG, "Invalid fault output index %u", this->fault_output_pin_);
|
||||
} else if (!this->write_byte(TAS58XX_GPIO0_SEL + this->fault_output_pin_, TAS58XX_GPIO_SEL_FAULTZ) ||
|
||||
!this->write_byte(TAS58XX_GPIO_CTRL, static_cast<uint8_t>(1 << this->fault_output_pin_))) {
|
||||
ESP_LOGE(TAG, "I2C write failed during init");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Setters are public, so keep the values inside the register ranges
|
||||
const uint8_t again =
|
||||
static_cast<uint8_t>(lroundf(clamp(-this->analog_gain_db_ * 2.0f, 0.0f, float{TAS58XX_AGAIN_MINUS_15_5DB})));
|
||||
if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) ||
|
||||
!this->write_byte(TAS58XX_AGAIN, again) || !this->write_volume_() ||
|
||||
!this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) ||
|
||||
const uint8_t device_ctrl_1 = (this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) |
|
||||
(this->modulation_ == MODULATION_1SPW_MODE ? TAS58XX_DEVICE_CTRL_1_1SPW : 0);
|
||||
if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, device_ctrl_1) || !this->write_byte(TAS58XX_AGAIN, again) ||
|
||||
!this->write_volume_() || !this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) ||
|
||||
!this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) {
|
||||
ESP_LOGE(TAG, "I2C write failed during init");
|
||||
return false;
|
||||
@@ -263,6 +281,14 @@ void TAS58xx::dump_config() {
|
||||
} else {
|
||||
mixer_mode = LOG_STR("Stereo");
|
||||
}
|
||||
const LogString *fault_output;
|
||||
if (this->fault_output_pin_ == FAULT_OUTPUT_NONE) {
|
||||
fault_output = LOG_STR("None");
|
||||
} else if (this->fault_output_pin_ >= this->model_->fault_output_pins) {
|
||||
fault_output = LOG_STR("Invalid");
|
||||
} else {
|
||||
fault_output = this->model_->fault_output_name(this->fault_output_pin_);
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Audio Amplifier:\n"
|
||||
" Model: %s",
|
||||
@@ -273,11 +299,14 @@ void TAS58xx::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Analog Gain: %.1f dB\n"
|
||||
" DAC Mode: %s\n"
|
||||
" Modulation: %s\n"
|
||||
" Mixer Mode: %s\n"
|
||||
" Volume Range: %.1f dB to %.1f dB",
|
||||
" Volume Range: %.1f dB to %.1f dB\n"
|
||||
" Fault Output: %s",
|
||||
this->analog_gain_db_,
|
||||
this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"),
|
||||
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_);
|
||||
this->modulation_ == MODULATION_1SPW_MODE ? LOG_STR_LITERAL("1SPW") : LOG_STR_LITERAL("BD"),
|
||||
LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_, LOG_STR_ARG(fault_output));
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_);
|
||||
for (auto *sensor : this->fault_binary_sensors_) {
|
||||
|
||||
@@ -22,6 +22,12 @@ enum DacMode : uint8_t {
|
||||
DAC_MODE_PBTL = 1, // Parallel bridge tied load, one speaker
|
||||
};
|
||||
|
||||
/// DEVICE_CTRL_1 DAMP_MOD values, the same on all models
|
||||
enum Modulation : uint8_t {
|
||||
MODULATION_BD_MODE = 0,
|
||||
MODULATION_1SPW_MODE = 1,
|
||||
};
|
||||
|
||||
enum MixerMode : uint8_t {
|
||||
MIXER_MODE_STEREO = 0,
|
||||
MIXER_MODE_STEREO_INVERSE,
|
||||
@@ -64,6 +70,8 @@ static_assert(
|
||||
|
||||
/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have.
|
||||
static constexpr uint8_t NO_BIT = 0xFF;
|
||||
/// fault_output_pin_ value for no fault output
|
||||
static constexpr uint8_t FAULT_OUTPUT_NONE = 0xFF;
|
||||
|
||||
/// Everything that differs between models of the family. One constant instance exists per model, see
|
||||
/// model_*.cpp, and each TAS58xx instance points to the one for its model.
|
||||
@@ -91,6 +99,10 @@ struct ModelInfo {
|
||||
const LogString *(*fault_name)(uint8_t index);
|
||||
/// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit
|
||||
uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT];
|
||||
/// Number of pins that can report FAULTZ: pin n is selected at 0x61 + n and enabled by bit n of 0x60
|
||||
uint8_t fault_output_pins;
|
||||
/// Name of FAULTZ pin n, for dump_config
|
||||
const LogString *(*fault_output_name)(uint8_t pin);
|
||||
};
|
||||
|
||||
#ifdef USE_TAS58XX_TAS5805M
|
||||
@@ -127,7 +139,10 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
|
||||
void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; }
|
||||
void set_analog_gain(float analog_gain_db) { this->analog_gain_db_ = analog_gain_db; }
|
||||
void set_dac_mode(DacMode dac_mode) { this->dac_mode_ = dac_mode; }
|
||||
/// pin: 0 to 2 for GPIOn on TAS5825M, 0 for ADR on TAS5805M; leave unset for no fault output
|
||||
void set_fault_output(uint8_t pin) { this->fault_output_pin_ = pin; }
|
||||
void set_mixer_mode(MixerMode mixer_mode) { this->mixer_mode_ = mixer_mode; }
|
||||
void set_modulation(Modulation modulation) { this->modulation_ = modulation; }
|
||||
void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; }
|
||||
void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; }
|
||||
|
||||
@@ -171,7 +186,9 @@ class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c:
|
||||
float volume_max_db_{24.0f};
|
||||
uint32_t logged_faults_{0}; // Fault bits as last logged, packed like the fault masks
|
||||
DacMode dac_mode_{DAC_MODE_BTL};
|
||||
uint8_t fault_output_pin_{FAULT_OUTPUT_NONE}; // No fault output: the pin keeps its power on state
|
||||
MixerMode mixer_mode_{MIXER_MODE_STEREO};
|
||||
Modulation modulation_{MODULATION_BD_MODE};
|
||||
uint8_t ctrl_state_{0};
|
||||
uint8_t power_state_{0xFF}; // Last POWER_STATE seen by update(), 0xFF until the first read
|
||||
bool mixer_written_{false}; // Mixer written since the last reset
|
||||
|
||||
@@ -2744,8 +2744,13 @@ def url(value):
|
||||
raise Invalid("Expected a file scheme or a URL scheme with host")
|
||||
|
||||
|
||||
def git_ref(value):
|
||||
if re.match(r"[a-zA-Z0-9_./-]+", value) is None:
|
||||
# Characters git never allows in a branch or tag name (git check-ref-format)
|
||||
GIT_REF_PATTERN = r"[^\s~^:?*\[\\]+"
|
||||
|
||||
|
||||
def git_ref(value: typing.Any) -> str:
|
||||
value = string_strict(value)
|
||||
if re.fullmatch(GIT_REF_PATTERN, value) is None:
|
||||
raise Invalid("Not a valid git ref")
|
||||
return value
|
||||
|
||||
@@ -3000,9 +3005,8 @@ def validate_source_shorthand(value):
|
||||
except Invalid:
|
||||
pass
|
||||
# Regex for GitHub repo name with optional branch/tag
|
||||
# Note: git allows other branch/tag names as well, but never seen them used before
|
||||
m = re.match(
|
||||
r"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@([a-zA-Z0-9\-_.\./]+))?|pr#([0-9]+))",
|
||||
m = re.fullmatch(
|
||||
rf"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@({GIT_REF_PATTERN}))?|pr#([0-9]+))",
|
||||
value,
|
||||
)
|
||||
if m is None:
|
||||
|
||||
+5
-5
@@ -718,9 +718,9 @@ def _clone_or_update_locked(
|
||||
# compute the same cache key as this one.
|
||||
original_url = url
|
||||
if username is not None and password is not None:
|
||||
url = url.replace(
|
||||
"://", f"://{urllib.parse.quote(username)}:{urllib.parse.quote(password)}@"
|
||||
)
|
||||
user = urllib.parse.quote(username, safe="")
|
||||
token = urllib.parse.quote(password, safe="")
|
||||
url = url.replace("://", f"://{user}:{token}@")
|
||||
|
||||
hash_dir_name = _compute_destination_path(key, domain).name
|
||||
repo_dir = _repo_entry_dir(key, domain, subpath)
|
||||
@@ -1051,8 +1051,8 @@ class GitFile:
|
||||
"""Parse a git shorthand URL into its components."""
|
||||
if not isinstance(shorthand, str):
|
||||
raise ValueError("Git shorthand must be a string")
|
||||
m = re.match(
|
||||
r"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>[a-zA-Z0-9\-_.\./]+))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
|
||||
m = re.fullmatch(
|
||||
rf"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>{cv.GIT_REF_PATTERN}))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
|
||||
shorthand,
|
||||
)
|
||||
if m is None:
|
||||
|
||||
@@ -2,7 +2,7 @@ dependencies:
|
||||
bblanchon/arduinojson:
|
||||
version: "7.4.3"
|
||||
esphome/dlms_parser:
|
||||
version: 2.2.0
|
||||
version: 2.2.2
|
||||
esphome/esp-audio-libs:
|
||||
version: 4.1.0
|
||||
esphome/esp-micro-speech-features:
|
||||
|
||||
+7
-2
@@ -284,12 +284,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
|
||||
_COMPONENT_CACHE[domain] = manif
|
||||
return manif
|
||||
|
||||
module_name = f"esphome.components.{domain}"
|
||||
try:
|
||||
module = importlib.import_module(f"esphome.components.{domain}")
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
if exception:
|
||||
raise
|
||||
if "No module named" in str(e):
|
||||
if (
|
||||
isinstance(e, ModuleNotFoundError)
|
||||
and e.name is not None
|
||||
and f"{module_name}.".startswith(f"{e.name}.")
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Unable to import component %s: %s", domain, str(e), exc_info=False
|
||||
)
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ build_unflags =
|
||||
; esp32 envs (which would double-include them).
|
||||
[common:idf-component-libs]
|
||||
lib_deps =
|
||||
esphome/dlms_parser@2.2.0 ; dlms_meter
|
||||
esphome/dlms_parser@2.2.2 ; dlms_meter
|
||||
bblanchon/ArduinoJson@7.4.3 ; json
|
||||
lvgl/lvgl@9.6.0 ; lvgl
|
||||
|
||||
|
||||
@@ -138,3 +138,26 @@ def test_task_log_buffer_size_unaligned_allowed_on_libretiny(
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
assert validate_task_log_buffer_alignment(641) == 641
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "log_v2"),
|
||||
[
|
||||
pytest.param("test_logger_idf6_esp_idf.yaml", True, id="native"),
|
||||
pytest.param("test_logger_idf6_platformio.yaml", False, id="platformio"),
|
||||
],
|
||||
)
|
||||
def test_log_v2_only_on_native_toolchain(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
config: str,
|
||||
log_v2: bool,
|
||||
) -> None:
|
||||
"""PlatformIO links the bootloader with the app's build flags, so the wrap has no target there."""
|
||||
from esphome.components.esp32 import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
|
||||
|
||||
generate_main(f"tests/component_tests/logger/{config}")
|
||||
|
||||
assert ("-Wl,--wrap=esp_log_format" in CORE.build_flags) is log_v2
|
||||
assert ("USE_ESP32_LOG_V2" in {d.name for d in CORE.defines}) is log_v2
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert ("CONFIG_LOG_VERSION_2" in sdkconfig) is log_v2
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
toolchain: esp-idf
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: 6.1.0
|
||||
|
||||
logger:
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
toolchain: platformio
|
||||
framework:
|
||||
type: esp-idf
|
||||
version: 6.1.0
|
||||
platform_version: 61.4.0-RC1
|
||||
|
||||
logger:
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tests that the TAS58xx fault_output and modulation defaults keep the existing behaviour."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_tas58xx_defaults_keep_the_fault_pin_and_bd_modulation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""Without fault_output or modulation each model keeps its usual fault pin and BD modulation."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml"
|
||||
)
|
||||
|
||||
assert "amp_5805_default->set_fault_output(0);" in main_cpp
|
||||
assert "amp_5825_default->set_fault_output(1);" in main_cpp
|
||||
for amp in ("amp_5805_default", "amp_5825_default", "amp_5805_none"):
|
||||
assert f"{amp}->set_modulation(tas58xx::MODULATION_BD_MODE);" in main_cpp
|
||||
|
||||
|
||||
def test_tas58xx_fault_output_none_leaves_the_pin_alone(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""fault_output: none does not configure a fault pin."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/tas58xx/test_tas58xx_fault_output_modulation.yaml"
|
||||
)
|
||||
|
||||
assert "amp_5805_none->set_fault_output(" not in main_cpp
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
i2c:
|
||||
id: i2c_bus
|
||||
sda: 21
|
||||
scl: 22
|
||||
|
||||
audio_dac:
|
||||
- platform: tas58xx
|
||||
model: tas5805m
|
||||
id: amp_5805_default
|
||||
ignore_enable_pin_warning: true
|
||||
- platform: tas58xx
|
||||
model: tas5825m
|
||||
id: amp_5825_default
|
||||
ignore_enable_pin_warning: true
|
||||
- platform: tas58xx
|
||||
model: tas5805m
|
||||
id: amp_5805_none
|
||||
address: 0x2C
|
||||
ignore_enable_pin_warning: true
|
||||
fault_output: none
|
||||
@@ -17,7 +17,7 @@ class TestMicrophone : public Microphone {
|
||||
void start() override {}
|
||||
void stop() override {}
|
||||
|
||||
void publish(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
|
||||
void publish(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
|
||||
this->publish_audio_(data, stream_info, capture_time_us);
|
||||
}
|
||||
bool has_callbacks() const { return this->has_data_callbacks_(); }
|
||||
@@ -56,20 +56,24 @@ TEST(MicrophoneDataCallbacks, CallbackGetsDataFormatAndCaptureTime) {
|
||||
});
|
||||
EXPECT_TRUE(mic.has_callbacks());
|
||||
|
||||
mic.publish(SAMPLES, STEREO_48K, 123456);
|
||||
std::vector<uint8_t> buffer = SAMPLES;
|
||||
mic.publish(buffer, STEREO_48K, 123456);
|
||||
EXPECT_EQ(data, SAMPLES);
|
||||
EXPECT_EQ(stream_info, STEREO_48K);
|
||||
EXPECT_EQ(capture_time_us, 123456);
|
||||
}
|
||||
|
||||
TEST(MicrophoneDataCallbacks, MutedCallbackGetsZeros) {
|
||||
TEST(MicrophoneDataCallbacks, MuteZeroesTheBufferInPlace) {
|
||||
TestMicrophone mic;
|
||||
std::vector<uint8_t> data;
|
||||
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
|
||||
|
||||
mic.set_mute_state(true);
|
||||
mic.publish(SAMPLES, STEREO_48K, 0);
|
||||
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
|
||||
std::vector<uint8_t> buffer = SAMPLES;
|
||||
mic.publish(buffer, STEREO_48K, 0);
|
||||
const std::vector<uint8_t> zeros(SAMPLES.size(), 0);
|
||||
EXPECT_EQ(data, zeros);
|
||||
EXPECT_EQ(buffer, zeros);
|
||||
}
|
||||
|
||||
TEST(MicrophoneDataCallbacks, DataTriggerPassesACopy) {
|
||||
@@ -115,4 +119,17 @@ TEST(MicrophoneDataCallbacks, DeprecatedCallUsesMicrophoneFormat) {
|
||||
EXPECT_EQ(capture_time_us, 0);
|
||||
}
|
||||
|
||||
// Remove in 2027.5.0 with the deprecated DataCallbackManager::call(data)
|
||||
TEST(MicrophoneDataCallbacks, DeprecatedCallDeliversZerosWhileMuted) {
|
||||
TestMicrophone mic;
|
||||
std::vector<uint8_t> data;
|
||||
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
|
||||
|
||||
mic.set_mute_state(true);
|
||||
const std::vector<uint8_t> buffer = SAMPLES;
|
||||
mic.publish_deprecated(buffer);
|
||||
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
|
||||
EXPECT_EQ(buffer, SAMPLES);
|
||||
}
|
||||
|
||||
} // namespace esphome::microphone::testing
|
||||
@@ -7,7 +7,9 @@ audio_dac:
|
||||
enable_pin: GPIO12
|
||||
analog_gain: -15.5dB
|
||||
dac_mode: btl
|
||||
fault_output: adr
|
||||
mixer_mode: stereo
|
||||
modulation: 1SPW_MODE
|
||||
volume_min_db: -60dB
|
||||
volume_max_db: 0dB
|
||||
update_interval: 1s
|
||||
|
||||
@@ -9,3 +9,4 @@ audio_dac:
|
||||
dac_mode: PBTL
|
||||
mixer_mode: mono
|
||||
analog_gain: -3.5dB
|
||||
fault_output: none
|
||||
@@ -0,0 +1,96 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-select-mode
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
auto_start: false
|
||||
responses:
|
||||
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x0C, 0x00,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00,
|
||||
0x06, 0x00,
|
||||
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Generic ACK for every other command
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
injections:
|
||||
# Sent after the select switched the module to Simple: "ON Range 0035\r\n" is only
|
||||
# parsed while the component expects simple frames.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
|
||||
0x30, 0x30, 0x33, 0x35,
|
||||
0x0D, 0x0A,
|
||||
]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
on_press:
|
||||
- lambda: 'id(mock_uart).start_scenario();'
|
||||
# A changed timeout gets apply_config_action() past its no-change check
|
||||
- platform: template
|
||||
name: "Apply Changed Config"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
id(ld2420_dev).new_config.timeout++;
|
||||
id(ld2420_dev).apply_config_action();
|
||||
- platform: template
|
||||
name: "Inject Simple Frame"
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// "ON Range 0040\r\n"
|
||||
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
|
||||
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, 0x30, 0x30, 0x34, 0x30, 0x0D, 0x0A});
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
moving_distance:
|
||||
name: "Moving Distance"
|
||||
|
||||
select:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
operating_mode:
|
||||
name: "Operating Mode"
|
||||
@@ -0,0 +1,73 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2420-mode-nack
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
responses:
|
||||
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
|
||||
- expect_tx:
|
||||
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x0C, 0x00,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00,
|
||||
0x06, 0x00,
|
||||
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# The simple system mode write is refused (error word 1)
|
||||
- expect_tx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00,
|
||||
0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0x12, 0x01,
|
||||
0x01, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
# Generic ACK for every other command
|
||||
- expect_tx: [0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x04, 0x00,
|
||||
0xFF, 0x01,
|
||||
0x00, 0x00,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
ld2420:
|
||||
id: ld2420_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
select:
|
||||
- platform: ld2420
|
||||
ld2420_id: ld2420_dev
|
||||
operating_mode:
|
||||
name: "Operating Mode"
|
||||
@@ -0,0 +1,41 @@
|
||||
esphome:
|
||||
name: uart-mock-pipsolar-switches
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy pipsolar's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 2400
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 2400
|
||||
auto_start: false
|
||||
|
||||
# No polling, so only the switch writes reach the command queue and no
|
||||
# polled status publishes the switch states
|
||||
pipsolar:
|
||||
id: inverter0
|
||||
uart_id: mock_uart
|
||||
update_interval: never
|
||||
|
||||
switch:
|
||||
- platform: pipsolar
|
||||
pipsolar_id: inverter0
|
||||
output_source_priority_utility:
|
||||
name: Priority Utility
|
||||
input_voltage_range:
|
||||
name: Input Voltage Range
|
||||
pv_power_balance:
|
||||
name: PV Power Balance
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Integration test for the LD2420 operating mode select with a mock UART.
|
||||
|
||||
The module runs firmware v2.0.0, so it starts in energy reporting. Choosing Simple
|
||||
must write the simple system mode to the module and parse simple frames afterwards;
|
||||
choosing Normal again must write the energy system mode back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SelectInfo, SelectState
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import (
|
||||
SensorStateCollector,
|
||||
require_entity,
|
||||
subscribe_after_initial_states,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
from .wait_utils import wait_or_fail
|
||||
|
||||
# CMD_WRITE_SYS_PARAM (0x0012), parameter 0x0000 (system mode), then the mode and 0x0000
|
||||
WRITE_SIMPLE = "FD:FC:FB:FA:08:00:12:00:00:00:64:00:00:00:04:03:02:01"
|
||||
WRITE_ENERGY = "FD:FC:FB:FA:08:00:12:00:00:00:04:00:00:00:04:03:02:01"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_select_mode(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The select switches the module between energy and simple reporting."""
|
||||
waiter = LineWaiter()
|
||||
collector = SensorStateCollector(sensor_names=["moving_distance"])
|
||||
simple_frame_parsed = collector.add_waiter(
|
||||
lambda: pytest.approx(35.0) in collector.sensor_states["moving_distance"]
|
||||
)
|
||||
second_frame_parsed = collector.add_waiter(
|
||||
lambda: pytest.approx(40.0) in collector.sensor_states["moving_distance"]
|
||||
)
|
||||
modes: list[str] = []
|
||||
back_to_normal = asyncio.Event()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
collector.on_state(state)
|
||||
if isinstance(state, SelectState):
|
||||
modes.append(state.state)
|
||||
if modes[-2:] == ["Calibrate", "Normal"]:
|
||||
back_to_normal.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
mode = require_entity(entities, "operating_mode", SelectInfo)
|
||||
apply_changed = require_entity(entities, "apply_changed_config", ButtonInfo)
|
||||
# Setup wrote energy mode for this firmware
|
||||
await waiter.wait_for("TX", WRITE_ENERGY)
|
||||
writes_before = waiter.count(WRITE_ENERGY)
|
||||
|
||||
client.select_command(mode.key, "Simple")
|
||||
await waiter.wait_for("TX", WRITE_SIMPLE)
|
||||
|
||||
client.button_command(
|
||||
require_entity(entities, "start_scenario", ButtonInfo).key
|
||||
)
|
||||
await wait_or_fail(simple_frame_parsed, 3.0, "Simple frame was not parsed")
|
||||
|
||||
# Applying a changed config in Simple keeps Simple
|
||||
client.button_command(apply_changed.key)
|
||||
await waiter.wait_for("Reconfiguring")
|
||||
inject = require_entity(entities, "inject_simple_frame", ButtonInfo)
|
||||
|
||||
async def inject_until_parsed() -> None:
|
||||
# Simple reports are rate limited, so keep sending until one is published
|
||||
while not second_frame_parsed.done():
|
||||
client.button_command(inject.key)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
await wait_or_fail(inject_until_parsed(), 5.0, "Simple stopped after apply")
|
||||
assert modes[-1] == "Simple"
|
||||
|
||||
client.select_command(mode.key, "Normal")
|
||||
await waiter.wait_count(
|
||||
WRITE_ENERGY, writes_before + 1, 3.0, "Normal did not write energy mode"
|
||||
)
|
||||
assert waiter.count(WRITE_ENERGY) == writes_before + 1
|
||||
|
||||
# Applying a changed config ends calibration
|
||||
client.select_command(mode.key, "Calibrate")
|
||||
client.button_command(apply_changed.key)
|
||||
await wait_or_fail(back_to_normal, 5.0, "Apply did not end calibration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2420_select_mode_nack(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A refused Simple write puts the select back on the mode the module reports."""
|
||||
waiter = LineWaiter()
|
||||
states: list[str] = []
|
||||
back_to_normal = asyncio.Event()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SelectState):
|
||||
states.append(state.state)
|
||||
if states[-2:] == ["Simple", "Normal"]:
|
||||
back_to_normal.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
await subscribe_after_initial_states(client, entities, on_state=on_state)
|
||||
mode = require_entity(entities, "operating_mode", SelectInfo)
|
||||
client.select_command(mode.key, "Simple")
|
||||
# Three tries of about a second each before the write is given up
|
||||
await wait_or_fail(back_to_normal, 6.0, "Select did not go back to Normal")
|
||||
assert waiter.count(WRITE_SIMPLE) == 3
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Integration test for the pipsolar switches with mock UART.
|
||||
|
||||
Each write queues the switch's on or off command on the hub, a direction with
|
||||
no command queues nothing, nothing is written at boot, and the write itself
|
||||
does not publish the switch state (the hub's polled status does).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from aioesphomeapi import EntityState, SwitchInfo
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .state_utils import require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
QUEUED = re.compile(r"Command queued successfully: (\w+) at position")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_pipsolar_switches(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Switch writes queue their commands and publish nothing themselves."""
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
waiter = LineWaiter()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
utility = require_entity(entities, "priority_utility", SwitchInfo)
|
||||
voltage_range = require_entity(entities, "input_voltage_range", SwitchInfo)
|
||||
balance = require_entity(entities, "pv_power_balance", SwitchInfo)
|
||||
|
||||
states: list[EntityState] = []
|
||||
initial = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
states.append(state)
|
||||
if len(states) == 3 and not initial.done():
|
||||
initial.set_result(None)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
await asyncio.wait_for(initial, timeout=5.0)
|
||||
assert all(state.missing_state for state in states)
|
||||
# Nothing is restored or written at boot
|
||||
assert waiter.count(QUEUED) == 0
|
||||
|
||||
async def queued_after(key: int, on: bool) -> str:
|
||||
future = waiter.expect(QUEUED)
|
||||
client.switch_command(key, on)
|
||||
match = await waiter.wait(future)
|
||||
return match.group(1)
|
||||
|
||||
sent = waiter.expect("Sending command from queue: POP00")
|
||||
assert await queued_after(utility.key, True) == "POP00"
|
||||
await waiter.wait(sent)
|
||||
|
||||
# The utility switch has no off command, so the next queued command
|
||||
# is the one written after it
|
||||
future = waiter.expect(QUEUED)
|
||||
client.switch_command(utility.key, False)
|
||||
client.switch_command(balance.key, True)
|
||||
assert (await waiter.wait(future)).group(1) == "PSPB1"
|
||||
assert await queued_after(balance.key, False) == "PSPB0"
|
||||
assert await queued_after(voltage_range.key, True) == "PGR01"
|
||||
assert await queued_after(voltage_range.key, False) == "PGR00"
|
||||
|
||||
# A fresh connection's snapshot shows the writes left every switch unpublished
|
||||
async with api_client_connected() as fresh:
|
||||
snapshot: list[EntityState] = []
|
||||
done = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_snapshot(state: EntityState) -> None:
|
||||
snapshot.append(state)
|
||||
if len(snapshot) == 3 and not done.done():
|
||||
done.set_result(None)
|
||||
|
||||
fresh.subscribe_states(on_snapshot)
|
||||
await asyncio.wait_for(done, timeout=5.0)
|
||||
assert all(state.missing_state for state in snapshot), snapshot
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for the esp32 framework ``components:`` schema."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32
|
||||
import esphome.config_validation as cv
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
"https://github.com/owner/component.git",
|
||||
"git@github.com:owner/component.git",
|
||||
],
|
||||
)
|
||||
def test_component_source_accepts_repository_url(source: str) -> None:
|
||||
config = esp32.FRAMEWORK_SCHEMA(
|
||||
{"components": [{"name": "owner/component", "source": source}]}
|
||||
)
|
||||
assert config["components"][0]["source"] == source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [1.0, ""])
|
||||
def test_component_source_rejects_invalid(source: object) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
esp32.FRAMEWORK_SCHEMA(
|
||||
{"components": [{"name": "owner/component", "source": source}]}
|
||||
)
|
||||
@@ -3121,9 +3121,21 @@ def test_git_ref_valid() -> None:
|
||||
assert cv.git_ref("v1.2.3") == "v1.2.3"
|
||||
|
||||
|
||||
def test_git_ref_invalid() -> None:
|
||||
@pytest.mark.parametrize("ref", ["main", "release/v6.0", "feat+x", "a1b2c3d4"])
|
||||
def test_git_ref_valid_chars(ref: str) -> None:
|
||||
assert cv.git_ref(ref) == ref
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref", ["main hi there", "a:b", "x~1", "", "main\n"])
|
||||
def test_git_ref_invalid(ref: str) -> None:
|
||||
with pytest.raises(Invalid, match="Not a valid git ref"):
|
||||
cv.git_ref("!!!")
|
||||
cv.git_ref(ref)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref", [1.0, 2025, None])
|
||||
def test_git_ref_not_string(ref: object) -> None:
|
||||
with pytest.raises(Invalid, match="Must be string"):
|
||||
cv.git_ref(ref)
|
||||
|
||||
|
||||
def test_source_refresh_always() -> None:
|
||||
@@ -3291,6 +3303,34 @@ def test_validate_source_shorthand_github_pr(setup_core: Path) -> None:
|
||||
assert result[CONF_REF] == "pull/1234/head"
|
||||
|
||||
|
||||
def test_validate_source_shorthand_github_ref_with_slash(setup_core: Path) -> None:
|
||||
result = cv.validate_source_shorthand("github://user/repo@release/v1")
|
||||
assert result[CONF_REF] == "release/v1"
|
||||
|
||||
|
||||
def test_validate_source_shorthand_github_ref_keeps_all_chars(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
result = cv.validate_source_shorthand("github://user/repo@feat+x")
|
||||
assert result[CONF_REF] == "feat+x"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"github://esphome/esphome/esphome/components@dev",
|
||||
"github://user/repo/",
|
||||
"github://user/repo@main hi",
|
||||
"github://pr#1234abc",
|
||||
],
|
||||
)
|
||||
def test_validate_source_shorthand_rejects_trailing_text(
|
||||
setup_core: Path, value: str
|
||||
) -> None:
|
||||
with pytest.raises(Invalid, match="not a file system path"):
|
||||
cv.validate_source_shorthand(value)
|
||||
|
||||
|
||||
def test_validate_source_shorthand_invalid(setup_core: Path) -> None:
|
||||
with pytest.raises(Invalid, match="not a file system path"):
|
||||
cv.validate_source_shorthand("notvalid")
|
||||
|
||||
@@ -10,6 +10,7 @@ import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
import urllib.parse
|
||||
|
||||
from filelock import FileLock
|
||||
import pytest
|
||||
@@ -2097,6 +2098,48 @@ def test_recovery_reclone_keeps_credentials_and_cache_key(
|
||||
assert clone_url.count("@") == 1
|
||||
|
||||
|
||||
def test_clone_credentials_with_slash_are_escaped(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A "/" in the username or password must be percent-encoded, or the
|
||||
userinfo ends at the "/" and git reports the password in its error."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
url = "https://github.com/test/repo"
|
||||
domain = "test"
|
||||
repo_dir = _compute_repo_dir(url, None, domain)
|
||||
|
||||
def git_command_side_effect(
|
||||
cmd: list[str], cwd: str | None = None, **kwargs: Any
|
||||
) -> str:
|
||||
if _get_git_command_type(cmd) == "clone":
|
||||
_simulate_cloned_repo(repo_dir)
|
||||
return ""
|
||||
|
||||
mock_run_git_command.side_effect = git_command_side_effect
|
||||
|
||||
git.clone_or_update(
|
||||
url=url,
|
||||
ref=None,
|
||||
refresh=TimePeriodSeconds(days=1),
|
||||
domain=domain,
|
||||
username="team/user",
|
||||
password="ab/cd@ef",
|
||||
)
|
||||
|
||||
clone_cmds = [
|
||||
c[0][0]
|
||||
for c in mock_run_git_command.call_args_list
|
||||
if _get_git_command_type(c[0][0]) == "clone"
|
||||
]
|
||||
assert clone_cmds
|
||||
clone_url = clone_cmds[0][-2]
|
||||
assert clone_url == "https://team%2Fuser:ab%2Fcd%40ef@github.com/test/repo"
|
||||
assert urllib.parse.urlsplit(clone_url).hostname == "github.com"
|
||||
redacted = git._redact_url_credentials(f"fatal: unable to access '{clone_url}/'")
|
||||
assert redacted == "fatal: unable to access 'https://***@github.com/test/repo/'"
|
||||
|
||||
|
||||
def test_refresh_submodule_failure_recovers_then_raises(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
@@ -3289,6 +3332,26 @@ def test_git_file_from_shorthand_without_ref_or_query() -> None:
|
||||
assert gf == git.GitFile("codeberg.org", "owner", "repo", "file.yaml")
|
||||
|
||||
|
||||
def test_git_file_from_shorthand_keeps_whole_ref() -> None:
|
||||
gf = git.GitFile.from_shorthand("github://owner/repo/file.yaml@feat+x?full_config")
|
||||
|
||||
assert gf.ref == "feat+x"
|
||||
assert gf.query == "full_config"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"shorthand",
|
||||
[
|
||||
"github://owner/repo/file.yaml@main hi",
|
||||
"github://owner/repo/my file.yaml",
|
||||
"github://owner/repo/file.yaml?full config",
|
||||
],
|
||||
)
|
||||
def test_git_file_from_shorthand_rejects_trailing_text(shorthand: str) -> None:
|
||||
with pytest.raises(ValueError, match="not in expected"):
|
||||
git.GitFile.from_shorthand(shorthand)
|
||||
|
||||
|
||||
def test_git_file_from_shorthand_requires_string() -> None:
|
||||
with pytest.raises(ValueError, match="must be a string"):
|
||||
git.GitFile.from_shorthand(123)
|
||||
|
||||
@@ -26,6 +26,7 @@ from esphome.loader import (
|
||||
clear_component_meta_finders,
|
||||
get_alias_metadata,
|
||||
get_component,
|
||||
get_platform,
|
||||
install_meta_finder,
|
||||
)
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
@@ -809,6 +810,44 @@ def test_lookup_module_import_error_missing_module_returns_none(
|
||||
assert "definitely_missing_component_xyz" not in _COMPONENT_CACHE
|
||||
|
||||
|
||||
def test_lookup_module_missing_platform_returns_none(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A platform file that does not exist is "not found", not an error."""
|
||||
with caplog.at_level(logging.INFO, logger="esphome.loader"):
|
||||
assert get_platform("definitely_missing_platform_xyz", "template") is None
|
||||
assert "Unable to import component template.definitely_missing_platform_xyz" in (
|
||||
caplog.text
|
||||
)
|
||||
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
|
||||
|
||||
def test_lookup_module_missing_dependency_is_logged_as_error(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A component that exists but imports a missing package reports that
|
||||
package as an error, instead of looking like a missing component."""
|
||||
comp = tmp_path / "depfailcomp"
|
||||
comp.mkdir()
|
||||
(comp / "__init__.py").write_text("import definitely_missing_package_xyz\n")
|
||||
saved = list(sys.meta_path)
|
||||
try:
|
||||
install_meta_finder(tmp_path)
|
||||
with caplog.at_level(logging.INFO, logger="esphome.loader"):
|
||||
assert get_component("depfailcomp") is None
|
||||
finally:
|
||||
sys.meta_path[:] = saved
|
||||
for name in [
|
||||
m for m in sys.modules if m.startswith("esphome.components.depfailcomp")
|
||||
]:
|
||||
del sys.modules[name]
|
||||
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(errors) == 1
|
||||
assert errors[0].getMessage() == "Unable to import component depfailcomp:"
|
||||
assert errors[0].exc_info is not None
|
||||
assert errors[0].exc_info[1].name == "definitely_missing_package_xyz"
|
||||
|
||||
|
||||
def test_lookup_module_missing_module_raises_when_requested() -> None:
|
||||
"""With ``exception=True`` the ImportError propagates."""
|
||||
with pytest.raises(ImportError):
|
||||
|
||||
Reference in new issue
Block a user