Merge remote-tracking branch 'origin/rp2040-upload-improvements' into integration

This commit is contained in:
J. Nick Koston
2026-03-09 17:49:47 -10:00
92 changed files with 1542 additions and 769 deletions
@@ -55,18 +55,24 @@ jobs:
return;
}
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (action === LabelAction.REMOVE) {
try {
try {
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (action === LabelAction.REMOVE) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr_number, name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
} catch (error) {
if (error.status !== 404) throw error;
}
} catch (error) {
if (error.status === 403) {
console.log(`Warning: insufficient permissions to update label (expected for fork PRs)`);
} else if (error.status === 404) {
console.log(`Label '${LABEL_NAME}' not present, nothing to remove`);
} else {
throw error;
}
}
+19 -3
View File
@@ -260,13 +260,17 @@ def choose_upload_log_host(
]
# Add RP2040 BOOTSEL device option when uploading
bootsel_permission_error = False
if (
purpose == Purpose.UPLOADING
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
and (picotool := _find_picotool()) is not None
and detect_rp2040_bootsel(picotool) > 0
):
options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL"))
bootsel = detect_rp2040_bootsel(picotool)
if bootsel.device_count > 0:
options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL"))
elif bootsel.permission_error:
bootsel_permission_error = True
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
@@ -291,6 +295,17 @@ def choose_upload_log_host(
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
):
if bootsel_permission_error:
_LOGGER.warning(
"An RP2040 device in BOOTSEL mode was detected but could "
"not be accessed due to USB permissions."
)
if sys.platform.startswith("linux"):
_LOGGER.warning(
"You may need to add a udev rule for RP2040 devices. "
"See: https://github.com/raspberrypi/picotool"
"/blob/master/udev/60-picotool.rules"
)
if not options:
raise EsphomeError(
f"No RP2040 device found. {_RP2040_BOOTSEL_INSTRUCTIONS}"
@@ -876,7 +891,8 @@ def upload_using_picotool(config: ConfigType) -> int:
if sys.platform.startswith("linux"):
msg += (
" You may need to add udev rules for RP2040 devices."
" See: https://github.com/raspberrypi/picotool#linux-permissions"
" See: https://github.com/raspberrypi/picotool"
"/blob/master/udev/60-picotool.rules"
)
_LOGGER.error(msg)
else:
+6 -3
View File
@@ -144,9 +144,12 @@ void Anova::update() {
return;
if (this->current_request_ < 2) {
auto *pkt = this->codec_->get_read_device_status_request();
if (this->current_request_ == 0)
this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
AnovaPacket *pkt;
if (this->current_request_ == 0) {
pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
} else {
pkt = this->codec_->get_read_device_status_request();
}
auto status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
+12
View File
@@ -155,6 +155,18 @@ APIConnection::~APIConnection() {
voice_assistant::global_voice_assistant->client_subscription(this, false);
}
#endif
#ifdef USE_ZWAVE_PROXY
if (zwave_proxy::global_zwave_proxy != nullptr && zwave_proxy::global_zwave_proxy->get_api_connection() == this) {
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE);
}
#endif
#ifdef USE_SERIAL_PROXY
for (auto *proxy : App.get_serial_proxies()) {
if (proxy->get_api_connection() == this) {
proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE);
}
}
#endif
}
void APIConnection::destroy_active_iterator_() {
@@ -373,6 +373,7 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
#ifdef USE_STORE_LOG_STR_IN_FLASH
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
memcpy_P(data + 1, reinterpret_cast<PGM_P>(reason), reason_len);
}
@@ -380,6 +381,7 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso
// Normal memory access
const char *reason_str = LOG_STR_ARG(reason);
size_t reason_len = strlen(reason_str);
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
std::memcpy(data + 1, reason_str, reason_len);
+5
View File
@@ -135,6 +135,11 @@ bool AT581XComponent::i2c_write_config() {
}
// Set gain
if (this->gain_ < 0 || static_cast<size_t>(this->gain_) >= ARRAY_SIZE(GAIN5C_TABLE) ||
static_cast<size_t>(this->gain_ >> 1) >= ARRAY_SIZE(GAIN63_TABLE)) {
ESP_LOGE(TAG, "AT581X gain index out of range: %d", this->gain_);
return false;
}
if (!this->i2c_write_reg(GAIN_ADDR_TABLE[0], GAIN5C_TABLE[this->gain_]) ||
!this->i2c_write_reg(GAIN_ADDR_TABLE[1], GAIN63_TABLE[this->gain_ >> 1])) {
ESP_LOGE(TAG, "Failed to write AT581X gain registers");
@@ -38,6 +38,11 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
const auto &data = service_data.data;
if (data.size() < 10) {
ESP_LOGW(TAG, "Service data too short: %zu", data.size());
return false;
}
const uint8_t protocol_version = data[0] >> 4;
if (protocol_version != 1 && protocol_version != 2) {
ESP_LOGE(TAG, "Unsupported protocol version: %u", protocol_version);
@@ -47,6 +52,11 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// Some b-parasite versions have an (optional) illuminance sensor.
bool has_illuminance = data[0] & 0x1;
if (has_illuminance && data.size() < 18) {
ESP_LOGW(TAG, "Service data too short for illuminance: %zu", data.size());
return false;
}
// Counter for deduplicating messages.
uint8_t counter = data[1] & 0x0f;
if (last_processed_counter_ == counter) {
@@ -136,7 +136,6 @@ optional<bool> SettleFilter::new_value(bool value) {
return {};
} else {
this->steady_ = false;
this->output(value);
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; });
return value;
}
+21 -22
View File
@@ -138,23 +138,24 @@ void BL0906::read_data_(const uint8_t address, const float reference, sensor::Se
this->write_byte(BL0906_READ_COMMAND);
this->write_byte(address);
if (this->read_array((uint8_t *) &buffer, sizeof(buffer) - 1)) {
if (bl0906_checksum(address, &buffer) == buffer.checksum) {
if (signed_result) {
data_s24.l = buffer.l;
data_s24.m = buffer.m;
data_s24.h = buffer.h;
} else {
data_u24.l = buffer.l;
data_u24.m = buffer.m;
data_u24.h = buffer.h;
}
} else {
ESP_LOGW(TAG, "Junk on wire. Throwing away partial message");
while (read() >= 0)
;
return;
}
if (!this->read_array((uint8_t *) &buffer, sizeof(buffer) - 1)) {
ESP_LOGW(TAG, "Read failed");
return;
}
if (bl0906_checksum(address, &buffer) != buffer.checksum) {
ESP_LOGW(TAG, "Junk on wire. Throwing away partial message");
while (read() >= 0)
;
return;
}
if (signed_result) {
data_s24.l = buffer.l;
data_s24.m = buffer.m;
data_s24.h = buffer.h;
} else {
data_u24.l = buffer.l;
data_u24.m = buffer.m;
data_u24.h = buffer.h;
}
// Power
if (reference == BL0906_PREF) {
@@ -190,11 +191,9 @@ void BL0906::bias_correction_(uint8_t address, float measurements, float correct
float i_rms0 = measurements * ki;
float i_rms = correction * ki;
int32_t value = (i_rms * i_rms - i_rms0 * i_rms0) / 256;
data.l = value << 24 >> 24;
data.m = value << 16 >> 24;
if (value < 0) {
data.h = (value << 8 >> 24) | 0b10000000;
}
data.l = value & 0xFF;
data.m = (value >> 8) & 0xFF;
data.h = (value >> 16) & 0xFF;
data.address = bl0906_checksum(address, &data);
ESP_LOGV(TAG, "RMSOS:%02X%02X%02X%02X%02X%02X", BL0906_WRITE_COMMAND, address, data.l, data.m, data.h, data.address);
this->write_byte(BL0906_WRITE_COMMAND);
+19 -4
View File
@@ -16,12 +16,27 @@ namespace ble_scanner {
class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
// Format JSON using stack buffer to avoid heap allocations from string concatenation
char buf[128];
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Escape special characters in the device name for valid JSON
const char *name = device.get_name().c_str();
char escaped_name[128];
size_t pos = 0;
for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) {
uint8_t c = static_cast<uint8_t>(*name);
if (c == '"' || c == '\\') {
escaped_name[pos++] = '\\';
escaped_name[pos++] = c;
} else if (c < 0x20) {
pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c);
} else {
escaped_name[pos++] = c;
}
}
escaped_name[pos] = '\0';
char buf[256];
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
static_cast<int64_t>(::time(nullptr)), device.address_str_to(addr_buf), device.get_rssi(),
device.get_name().c_str());
static_cast<int64_t>(::time(nullptr)), device.address_str_to(addr_buf), device.get_rssi(), escaped_name);
this->publish_state(buf);
return true;
}
@@ -147,8 +147,11 @@ void BME280Component::setup() {
this->calibration_.h1 = read_u8_(BME280_REGISTER_DIG_H1);
this->calibration_.h2 = read_s16_le_(BME280_REGISTER_DIG_H2);
this->calibration_.h3 = read_u8_(BME280_REGISTER_DIG_H3);
this->calibration_.h4 = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F);
this->calibration_.h5 = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4);
// h4 and h5 are signed 12-bit values; shift left then arithmetic right shift to sign-extend
int16_t h4_raw = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F);
this->calibration_.h4 = static_cast<int16_t>(h4_raw << 4) >> 4;
int16_t h5_raw = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4);
this->calibration_.h5 = static_cast<int16_t>(h5_raw << 4) >> 4;
this->calibration_.h6 = read_u8_(BME280_REGISTER_DIG_H6);
uint8_t humid_control_val = 0;
+2 -2
View File
@@ -32,8 +32,8 @@ enum BME680Oversampling {
/// Struct for storing calibration data for the BME680.
struct BME680CalibrationData {
uint16_t t1;
uint16_t t2;
uint8_t t3;
int16_t t2;
int8_t t3;
uint16_t p1;
int16_t p2;
@@ -271,10 +271,16 @@ void BME680BSECComponent::read_() {
int64_t curr_time_ns = this->get_time_ns_();
if (this->bme680_settings_.trigger_measurement) {
uint32_t start = millis();
while (this->bme680_.power_mode != BME680_SLEEP_MODE) {
if (millis() - start > 50) {
ESP_LOGE(TAG, "Timeout waiting for BME680 to enter sleep mode");
return;
}
this->bme680_status_ = bme680_get_sensor_mode(&this->bme680_);
if (this->bme680_status_ != BME680_OK) {
ESP_LOGW(TAG, "Failed to get sensor mode (BME680 Error Code %d)", this->bme680_status_);
ESP_LOGE(TAG, "Failed to get sensor mode (BME680 Error Code %d)", this->bme680_status_);
return;
}
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ namespace esphome {
namespace bmi160 {
static const char *const TAG = "bmi160";
static constexpr uint32_t GYRO_WAKEUP_TIMEOUT_MS = 100;
const uint8_t BMI160_REGISTER_CHIPID = 0x00;
@@ -144,7 +145,7 @@ void BMI160Component::internal_setup_(int stage) {
}
ESP_LOGV(TAG, " Waiting for gyroscope to wake up");
// wait between 51 & 81ms, doing 100 to be safe
this->set_timeout(10, [this]() { this->internal_setup_(2); });
this->set_timeout(GYRO_WAKEUP_TIMEOUT_MS, [this]() { this->internal_setup_(2); });
break;
case 2:
+2 -1
View File
@@ -1,4 +1,5 @@
#include "canbus.h"
#include <algorithm>
#include "esphome/core/log.h"
namespace esphome {
@@ -82,7 +83,7 @@ void Canbus::loop() {
std::vector<uint8_t> data;
// show data received
for (int i = 0; i < can_message.can_data_length_code; i++) {
for (int i = 0; i < std::min(can_message.can_data_length_code, CAN_MAX_DATA_LENGTH); i++) {
ESP_LOGV(TAG, " can_message.data[%d]=%02x", i, can_message.data[i]);
data.push_back(can_message.data[i]);
}
@@ -14,6 +14,7 @@ static const char *const TAG = "captive_portal.dns";
// DNS constants
static constexpr uint16_t DNS_PORT = 53;
static constexpr uint16_t DNS_QR_FLAG = 1 << 15;
static constexpr uint16_t DNS_AA_FLAG = 1 << 10;
static constexpr uint16_t DNS_OPCODE_MASK = 0x7800;
static constexpr uint16_t DNS_QTYPE_A = 0x0001;
static constexpr uint16_t DNS_QCLASS_IN = 0x0001;
@@ -162,8 +163,8 @@ void DNSServer::process_next_request() {
}
// Build DNS response by modifying the request in-place
header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative
header->an_count = htons(1); // One answer
header->flags = htons(DNS_QR_FLAG | DNS_AA_FLAG); // Response + Authoritative
header->an_count = htons(1); // One answer
// Add answer section after the question
size_t question_len = (ptr + sizeof(DNSQuestion)) - this->buffer_ - sizeof(DNSHeader);
+5 -4
View File
@@ -2,6 +2,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::cse7766 {
@@ -192,12 +193,12 @@ void CSE7766Component::parse_data_() {
this->apparent_power_sensor_->publish_state(apparent_power);
}
if (have_power && this->reactive_power_sensor_ != nullptr) {
const float reactive_power = apparent_power - power;
if (reactive_power < 0.0f) {
ESP_LOGD(TAG, "Impossible reactive power: %.4f is negative", reactive_power);
const float q_squared = apparent_power * apparent_power - power * power;
if (q_squared < 0.0f) {
ESP_LOGD(TAG, "Impossible reactive power: S^2-P^2 is negative (%.4f)", q_squared);
this->reactive_power_sensor_->publish_state(0.0f);
} else {
this->reactive_power_sensor_->publish_state(reactive_power);
this->reactive_power_sensor_->publish_state(std::sqrt(q_squared));
}
}
if (this->power_factor_sensor_ != nullptr && (have_power || power_cycle_exceeds_range)) {
+5 -4
View File
@@ -350,8 +350,9 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) {
if (data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) {
valid_daikin_frame = true;
size_t bytes_count = data.size() / 2 / 8;
size_t buf_size = bytes_count * 3 + 1;
std::unique_ptr<char[]> buf(new char[buf_size]()); // value-initialize (zero-fill)
// Header (20) + state (19) = 39 bytes max; truncates gracefully via buf_append_printf
char buf[40 * 3 + 1] = {};
constexpr size_t buf_size = sizeof(buf);
size_t buf_pos = 0;
for (size_t i = 0; i < bytes_count; i++) {
uint8_t byte = 0;
@@ -363,9 +364,9 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) {
break;
}
}
buf_pos = buf_append_printf(buf.get(), buf_size, buf_pos, "%02x ", byte);
buf_pos = buf_append_printf(buf, buf_size, buf_pos, "%02x ", byte);
}
ESP_LOGD(TAG, "WHOLE FRAME %s size: %d", buf.get(), data.size());
ESP_LOGD(TAG, "WHOLE FRAME %s size: %d", buf, data.size());
}
if (!valid_daikin_frame) {
char sbuf[16 * 10 + 1] = {0};
@@ -32,8 +32,8 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component {
auto code = call.get_code();
switch (state) {
case ACP_STATE_ARMED_AWAY:
if (this->get_requires_code_to_arm() && code.has_value()) {
if (*code != "1234") {
if (this->get_requires_code_to_arm()) {
if (!code.has_value() || *code != "1234") {
this->status_momentary_error("invalid_code", 5000);
return;
}
@@ -41,8 +41,8 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component {
this->publish_state(ACP_STATE_ARMED_AWAY);
break;
case ACP_STATE_DISARMED:
if (this->get_requires_code() && code.has_value()) {
if (*code != "1234") {
if (this->get_requires_code()) {
if (!code.has_value() || *code != "1234") {
this->status_momentary_error("invalid_code", 5000);
return;
}
+2 -1
View File
@@ -1,3 +1,4 @@
#include <cstddef>
#include <cstring>
#include "e131.h"
#ifdef USE_NETWORK
@@ -57,7 +58,7 @@ union E131RawPacket {
// We need to have at least one `1` value
// Get the offset of `property_values[1]`
const size_t E131_MIN_PACKET_SIZE = reinterpret_cast<size_t>(&((E131RawPacket *) nullptr)->property_values[1]);
const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t);
bool E131Component::join_igmp_groups_() {
if (this->listen_method_ != E131_MULTICAST)
+2 -2
View File
@@ -152,7 +152,7 @@ void ES8388::dump_config() {
bool ES8388::set_volume(float volume) {
volume = clamp(volume, 0.0f, 1.0f);
uint8_t value = remap<uint8_t, float>(volume, 0.0f, 1.0f, -96, 0);
uint8_t value = remap<uint8_t, float>(volume, 0.0f, 1.0f, 192, 0);
ESP_LOGD(TAG, "Setting ES8388_DACCONTROL4 / ES8388_DACCONTROL5 to 0x%02X (volume: %f)", value, volume);
ES8388_ERROR_CHECK(this->write_byte(ES8388_DACCONTROL4, value));
ES8388_ERROR_CHECK(this->write_byte(ES8388_DACCONTROL5, value));
@@ -163,7 +163,7 @@ bool ES8388::set_volume(float volume) {
float ES8388::volume() {
uint8_t value;
ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL4, &value));
return remap<float, uint8_t>(value, -96, 0, 0.0f, 1.0f);
return remap<float, uint8_t>(value, 192, 0, 0.0f, 1.0f);
}
bool ES8388::set_mute_state_(bool mute_state) {
@@ -514,6 +514,11 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) {
continue; // Possible zero padded advertisement data
}
// Validate field fits in remaining payload
if (offset + field_length > len) {
break;
}
// first byte of adv record is adv record type
const uint8_t record_type = payload[offset++];
const uint8_t *record = &payload[offset];
@@ -544,7 +549,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) {
// CSS 1.5 TX POWER LEVEL
// "The TX Power Level data type indicates the transmitted power level of the packet containing the data type."
// CSS 1: Optional in this context (may appear more than once in a block).
this->tx_powers_.push_back(*payload);
this->tx_powers_.push_back(*record);
break;
}
case ESP_BLE_AD_TYPE_APPEARANCE: {
@@ -146,6 +146,10 @@ void ESP32Camera::dump_config() {
}
sensor_t *s = esp_camera_sensor_get();
if (s == nullptr) {
ESP_LOGE(TAG, " Camera sensor not available");
return;
}
auto st = s->status;
ESP_LOGCONFIG(TAG,
" JPEG Quality: %u\n"
@@ -483,6 +487,9 @@ void ESP32Camera::request_image(camera::CameraRequester requester) { this->singl
camera::CameraImageReader *ESP32Camera::create_image_reader() { return new ESP32CameraImageReader; }
void ESP32Camera::update_camera_parameters() {
sensor_t *s = esp_camera_sensor_get();
if (s == nullptr) {
return;
}
/* update image */
s->set_vflip(s, this->vertical_flip_);
s->set_hmirror(s, this->horizontal_mirror_);
@@ -84,7 +84,7 @@ template<typename T, uint8_t SZ> class RestoringGlobalStringComponent : public P
this->rtc_ = global_preferences->make_preference<uint8_t[SZ]>(1944399030U ^ this->name_hash_);
bool hasdata = this->rtc_.load(&temp);
if (hasdata) {
this->value_.assign(temp + 1, temp[0]);
this->value_.assign(temp + 1, static_cast<uint8_t>(temp[0]));
}
this->last_checked_value_.assign(this->value_);
}
@@ -139,7 +139,8 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps,
}
void GroveMotorDriveTB6612FNG::stepper_stop() {
if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_STOP, nullptr, 1) != i2c::ERROR_OK) {
uint8_t status = 0;
if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_STOP, &status, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Send stop stepper failed!");
this->status_set_warning();
return;
+1 -1
View File
@@ -114,7 +114,7 @@ void HeatpumpIRClimate::setup() {
this->current_temperature = state;
IRSenderESPHome esp_sender(this->transmitter_);
this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature + 0.5)));
this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature)));
// current temperature changed, publish state
this->publish_state();
@@ -96,7 +96,7 @@ class HitachiClimate : public climate_ir::ClimateIR {
void set_power_(bool on);
uint8_t get_mode_();
void set_mode_(uint8_t mode);
void set_temp_(uint8_t celsius, bool set_previous = false);
void set_temp_(uint8_t celsius, bool set_previous = true);
uint8_t get_fan_();
void set_fan_(uint8_t speed);
void set_swing_v_toggle_(bool on);
@@ -6,6 +6,7 @@
namespace esphome::hlk_fm22x {
static const char *const TAG = "hlk_fm22x";
static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20;
void HlkFm22xComponent::setup() {
ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X...");
@@ -133,6 +134,21 @@ void HlkFm22xComponent::recv_command_() {
checksum ^= byte;
length |= byte;
// Wait for remaining data (payload + checksum) to arrive.
// Header bytes are already consumed, so we must finish reading this message.
uint32_t start = millis();
while (this->available() < length + 1) {
if (millis() - start > PAYLOAD_TIMEOUT_MS) {
ESP_LOGE(TAG, "Timeout waiting for payload (%u bytes)", length);
// Drain any partial payload bytes to resync the parser
while (this->available() > 0) {
this->read();
}
return;
}
delay(1);
}
// Read up to buffer size; discard excess bytes while still computing checksum
// GET_ALL_FACE_IDS can return all enrolled face data (hundreds of bytes)
// but handlers only need the first few bytes
+1 -1
View File
@@ -61,7 +61,7 @@ class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice {
NONE = 0,
COMMUNICATION_FAILED,
ID_REGISTERS,
} error_code_;
} error_code_{NONE};
HighFrequencyLoopRequester high_freq_;
};
@@ -45,8 +45,8 @@ class HONEYWELLABP2Sensor : public PollingComponent, public i2c::I2CDevice {
const float max_count_b_ = 11744051.2; // (70% of 2^24 counts or 0xB33333)
const float min_count_b_ = 5033164.8; // (30% of 2^24 counts or 0x4CCCCC)
float max_count_;
float min_count_;
float max_count_{max_count_a_};
float min_count_{min_count_a_};
bool measurement_running_ = false;
uint8_t raw_data_[7]; // holds output data
@@ -110,9 +110,17 @@ void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) {
for (int i = 0; i < buffer_len; i++) {
if (buffer[i] == 0x06 || buffer[i] == 0x0d || buffer[i] == 0x1b || buffer[i] == 0x40 || buffer[i] == 0x80) {
if (tx_msg_len + 2 >= static_cast<int>(sizeof(tx_msg))) {
ESP_LOGE(TAG, "TX message overflow");
return;
}
tx_msg[tx_msg_len++] = 0x1b;
tx_msg[tx_msg_len++] = buffer[i] ^ 0xff;
} else {
if (tx_msg_len + 1 >= static_cast<int>(sizeof(tx_msg))) {
ESP_LOGE(TAG, "TX message overflow");
return;
}
tx_msg[tx_msg_len++] = buffer[i];
}
}
@@ -216,8 +224,8 @@ void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_
uint8_t unit_idx = msg[4];
uint8_t mantissa_range = msg[5];
if (mantissa_range > 4) {
ESP_LOGE(TAG, "Received invalid message (mantissa size too large %d, expected 4)", mantissa_range);
if (mantissa_range > 4 || msg_len < 7 + mantissa_range) {
ESP_LOGE(TAG, "Received invalid message (mantissa size %d, msg_len %d)", mantissa_range, msg_len);
return;
}
+22 -17
View File
@@ -413,24 +413,29 @@ void LD2412Component::handle_periodic_data_() {
this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance);
}
if (engineering_mode) {
/*
Moving distance range: 18th byte
Still distance range: 19th byte
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
if (this->buffer_pos_ < LIGHT_SENSOR + 1) {
ESP_LOGW(TAG, "Engineering mode packet too short: %u", this->buffer_pos_);
} else {
/*
Moving distance range: 18th byte
Still distance range: 19th byte
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
}
/*
Light sensor value
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
}
/*
Light sensor: 38th bytes
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
+1 -1
View File
@@ -133,7 +133,7 @@ static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC};
// MAC address the module uses when Bluetooth is disabled
static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01};
static inline uint16_t convert_seconds_to_ms(uint16_t value) { return value * 1000; };
static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; };
static inline void convert_int_values_to_hex(const int *values, uint8_t *bytes) {
for (uint8_t i = 0; i < 4; i++) {
+1 -1
View File
@@ -168,7 +168,7 @@ class LD2450Component : public Component, public uart::UARTDevice {
uint32_t presence_millis_ = 0;
uint32_t still_presence_millis_ = 0;
uint32_t moving_presence_millis_ = 0;
uint16_t timeout_ = 5;
uint32_t timeout_ = 5;
uint8_t buffer_data_[MAX_LINE_LENGTH];
uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0};
uint8_t version_[6] = {0, 0, 0, 0, 0, 0};
+3
View File
@@ -76,6 +76,9 @@ esp_err_t configure_timer_frequency(ledc_mode_t speed_mode, ledc_timer_t timer_n
init_result = ledc_timer_config(&timer_conf);
if (init_result != ESP_OK) {
ESP_LOGW(TAG, "Unable to initialize timer with frequency %.1f and bit depth of %u", frequency, bit_depth);
if (bit_depth <= 1) {
break;
}
// try again with a lower bit depth
timer_conf.duty_resolution = static_cast<ledc_timer_bit_t>(--bit_depth);
}
@@ -324,6 +324,8 @@ class AddressableFireworksEffect : public AddressableLightEffect {
target *= 170;
view = target;
}
if (it.size() < 2)
return;
int last = it.size() - 1;
it[0].set(it[0].get() + (it[1].get() * 128));
for (int i = 1; i < last; i++) {
+6
View File
@@ -163,8 +163,11 @@ void LvglComponent::show_page(size_t index, lv_scr_load_anim_t anim, uint32_t ti
void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) {
if (this->pages_.empty() || (this->current_page_ == this->pages_.size() - 1 && !this->page_wrap_))
return;
size_t start = this->current_page_;
do {
this->current_page_ = (this->current_page_ + 1) % this->pages_.size();
if (this->current_page_ == start)
return; // all pages have skip=true (guaranteed not to happen by YAML validation)
} while (this->pages_[this->current_page_]->skip); // skip empty pages()
this->show_page(this->current_page_, anim, time);
}
@@ -172,8 +175,11 @@ void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) {
void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) {
if (this->pages_.empty() || (this->current_page_ == 0 && !this->page_wrap_))
return;
size_t start = this->current_page_;
do {
this->current_page_ = (this->current_page_ + this->pages_.size() - 1) % this->pages_.size();
if (this->current_page_ == start)
return; // all pages have skip=true (guaranteed not to happen by YAML validation)
} while (this->pages_[this->current_page_]->skip); // skip empty pages()
this->show_page(this->current_page_, anim, time);
}
+3 -1
View File
@@ -35,7 +35,9 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(MAX9611Component),
cv.Required(CONF_SHUNT_RESISTANCE): cv.resistance,
cv.Required(CONF_SHUNT_RESISTANCE): cv.All(
cv.resistance, cv.Range(min=1e-6)
),
cv.Required(CONF_GAIN): cv.enum(MAX9611_GAIN, upper=True),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
@@ -47,12 +47,12 @@ void MCP23X08Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterr
case mcp23xxx_base::MCP23XXX_RISING:
this->update_reg(pin, true, gpinten);
this->update_reg(pin, true, intcon);
this->update_reg(pin, true, defval);
this->update_reg(pin, false, defval);
break;
case mcp23xxx_base::MCP23XXX_FALLING:
this->update_reg(pin, true, gpinten);
this->update_reg(pin, true, intcon);
this->update_reg(pin, false, defval);
this->update_reg(pin, true, defval);
break;
case mcp23xxx_base::MCP23XXX_NO_INTERRUPT:
this->update_reg(pin, false, gpinten);
@@ -59,12 +59,12 @@ void MCP23X17Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterr
case mcp23xxx_base::MCP23XXX_RISING:
this->update_reg(pin, true, gpinten);
this->update_reg(pin, true, intcon);
this->update_reg(pin, true, defval);
this->update_reg(pin, false, defval);
break;
case mcp23xxx_base::MCP23XXX_FALLING:
this->update_reg(pin, true, gpinten);
this->update_reg(pin, true, intcon);
this->update_reg(pin, false, defval);
this->update_reg(pin, true, defval);
break;
case mcp23xxx_base::MCP23XXX_NO_INTERRUPT:
this->update_reg(pin, false, gpinten);
+1
View File
@@ -506,6 +506,7 @@ canbus::Error MCP2515::set_bitrate_(canbus::CanSpeed can_speed, CanClock can_clo
cfg3 = MCP_12MHZ_40KBPS_CFG3;
break;
case (canbus::CAN_50KBPS): // 50Kbps
cfg1 = MCP_12MHZ_50KBPS_CFG1;
cfg2 = MCP_12MHZ_50KBPS_CFG2;
cfg3 = MCP_12MHZ_50KBPS_CFG3;
break;
+6 -6
View File
@@ -79,8 +79,8 @@ void Mcp4461Component::dump_config() {
// reworked to be a one-line intentionally, as output would not be in order
if (i < 4) {
ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i,
this->reg_[i].state, ONOFF(this->reg_[i].terminal_hw), ONOFF(this->reg_[i].terminal_a),
ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w), ONOFF(this->reg_[i].enabled));
this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw),
ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w));
} else {
ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state);
}
@@ -315,9 +315,9 @@ void Mcp4461Component::disable_wiper_(Mcp4461WiperIdx wiper) {
return;
}
ESP_LOGV(TAG, "Disabling wiper %u", wiper_idx);
this->reg_[wiper_idx].enabled = true;
this->reg_[wiper_idx].enabled = false;
if (wiper_idx < 4) {
this->reg_[wiper_idx].terminal_hw = true;
this->reg_[wiper_idx].terminal_hw = false;
this->reg_[wiper_idx].update_terminal = true;
}
}
@@ -490,7 +490,7 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
ESP_LOGW(TAG, "Unknown terminal %c specified", terminal);
return;
}
this->reg_[wiper_idx].update_terminal = false;
this->reg_[wiper_idx].update_terminal = true;
}
void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
@@ -517,7 +517,7 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
ESP_LOGW(TAG, "Unknown terminal %c specified", terminal);
return;
}
this->reg_[wiper_idx].update_terminal = false;
this->reg_[wiper_idx].update_terminal = true;
}
uint16_t Mcp4461Component::get_eeprom_value(Mcp4461EepromLocation location) {
+1 -1
View File
@@ -58,7 +58,7 @@ class MCP4728Component : public Component, public i2c::I2CDevice {
void select_gain_(MCP4728ChannelIdx channel, MCP4728Gain gain);
private:
DACInputData reg_[4];
DACInputData reg_[4]{};
bool store_in_eeprom_ = false;
bool update_ = false;
};
+4 -2
View File
@@ -28,12 +28,14 @@ class UARTStream : public Stream {
int available() override { return this->uart_->available(); }
int read() override {
uint8_t data;
this->uart_->read_byte(&data);
if (!this->uart_->read_byte(&data))
return -1;
return data;
}
int peek() override {
uint8_t data;
this->uart_->peek_byte(&data);
if (!this->uart_->peek_byte(&data))
return -1;
return data;
}
size_t write(uint8_t data) override {
+2 -2
View File
@@ -27,7 +27,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice {
void set_auto_set_reset(bool auto_set_reset) { auto_set_reset_ = auto_set_reset; }
protected:
MMC5603Datarate datarate_;
MMC5603Datarate datarate_{MMC5603_DATARATE_75_0_HZ};
sensor::Sensor *x_sensor_{nullptr};
sensor::Sensor *y_sensor_{nullptr};
sensor::Sensor *z_sensor_{nullptr};
@@ -37,7 +37,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice {
NONE = 0,
COMMUNICATION_FAILED,
ID_REGISTERS,
} error_code_;
} error_code_{NONE};
};
} // namespace mmc5603
@@ -59,6 +59,10 @@ bool ModbusController::send_next_command_() {
// Queue incoming response
void ModbusController::on_modbus_data(const std::vector<uint8_t> &data) {
if (this->command_queue_.empty()) {
ESP_LOGW(TAG, "Received modbus data but command queue is empty");
return;
}
auto &current_command = this->command_queue_.front();
if (current_command != nullptr) {
if (this->module_offline_) {
@@ -92,6 +96,9 @@ void ModbusController::process_modbus_data_(const ModbusCommandItem *response) {
void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_code) {
ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, exception_code);
if (this->command_queue_.empty()) {
return;
}
// Remove pending command waiting for a response
auto &current_command = this->command_queue_.front();
if (current_command != nullptr) {
@@ -175,6 +182,11 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st
uint16_t payload_offset;
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (data.size() < 5) {
ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size());
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8);
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers);
@@ -188,8 +200,19 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
if (data.size() < 5 + payload_size) {
ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(),
5 + payload_size);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
payload_offset = 5;
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
if (data.size() < 4) {
ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size());
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
number_of_registers = 1;
payload_offset = 2;
} else {
@@ -126,18 +126,18 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
// Copy measurements over into my array.
{
u_int8_t measurements_index = 0;
for (u_int8_t i = 0; i < 3; i++) {
measurements_time[measurements_index] = mopeka_data->val[i].time_0 + 1;
measurements_value[measurements_index] = mopeka_data->val[i].value_0;
for (const auto &val : mopeka_data->val) {
measurements_time[measurements_index] = val.time_0 + 1;
measurements_value[measurements_index] = val.value_0;
measurements_index++;
measurements_time[measurements_index] = mopeka_data->val[i].time_1 + 1;
measurements_value[measurements_index] = mopeka_data->val[i].value_1;
measurements_time[measurements_index] = val.time_1 + 1;
measurements_value[measurements_index] = val.value_1;
measurements_index++;
measurements_time[measurements_index] = mopeka_data->val[i].time_2 + 1;
measurements_value[measurements_index] = mopeka_data->val[i].value_2;
measurements_time[measurements_index] = val.time_2 + 1;
measurements_value[measurements_index] = val.value_2;
measurements_index++;
measurements_time[measurements_index] = mopeka_data->val[i].time_3 + 1;
measurements_value[measurements_index] = mopeka_data->val[i].value_3;
measurements_time[measurements_index] = val.time_3 + 1;
measurements_value[measurements_index] = val.value_3;
measurements_index++;
}
}
@@ -40,7 +40,7 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru
bool slow_update_rate : 1;
bool sync_pressed : 1;
mopeka_std_values val[4];
mopeka_std_values val[3];
} __attribute__((packed));
class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
+3 -1
View File
@@ -44,8 +44,10 @@ MQTTClientComponent::MQTTClientComponent() {
void MQTTClientComponent::setup() {
this->mqtt_backend_.set_on_message(
[this](const char *topic, const char *payload, size_t len, size_t index, size_t total) {
if (index == 0)
if (index == 0) {
this->payload_buffer_.clear();
this->payload_buffer_.reserve(total);
}
// append new payload, may contain incomplete MQTT message
this->payload_buffer_.append(payload, len);
+5 -11
View File
@@ -646,16 +646,12 @@ void Nextion::process_nextion_commands_() {
break;
}
if (to_process_length == 0) {
ESP_LOGE(TAG, "Numeric return but no data");
if (to_process_length < 4) {
ESP_LOGE(TAG, "Numeric return but insufficient data (need 4, got %zu)", to_process_length);
break;
}
int value = 0;
for (int i = 0; i < 4; ++i) {
value += to_process[i] << (8 * i);
}
int value = static_cast<int>(encode_uint32(to_process[3], to_process[2], to_process[1], to_process[0]));
NextionQueue *nb = this->nextion_queue_.front();
if (!nb || !nb->component) {
@@ -751,10 +747,8 @@ void Nextion::process_nextion_commands_() {
index = to_process.find('\0');
variable_name = to_process.substr(0, index);
// // Get variable name
int value = 0;
for (int i = 0; i < 4; ++i) {
value += to_process[i + index + 1] << (8 * i);
}
int value = static_cast<int>(
encode_uint32(to_process[index + 4], to_process[index + 3], to_process[index + 2], to_process[index + 1]));
ESP_LOGN(TAG, "Sensor: %s=%d", variable_name.c_str(), value);
@@ -86,6 +86,12 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) {
ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_,
EspClass::getFreeHeap());
upload_first_chunk_sent_ = true;
if (recv_string.empty()) {
ESP_LOGW(TAG, "No response from display during upload");
allocator.deallocate(buffer, 4096);
buffer = nullptr;
return -1;
}
if (recv_string[0] == 0x08 && recv_string.size() == 5) { // handle partial upload request
char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)];
ESP_LOGD(
@@ -108,6 +108,12 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
static_cast<uint32_t>(esp_get_free_heap_size()));
#endif
upload_first_chunk_sent_ = true;
if (recv_string.empty()) {
ESP_LOGW(TAG, "No response from display during upload");
allocator.deallocate(buffer, 4096);
buffer = nullptr;
return -1;
}
if (recv_string[0] == 0x08 && recv_string.size() == 5) { // handle partial upload request
char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)];
ESP_LOGD(
+7 -2
View File
@@ -192,8 +192,13 @@ bool Pipsolar::send_next_command_() {
if (!this->command_queue_[this->command_queue_position_].empty()) {
const char *command = this->command_queue_[this->command_queue_position_].c_str();
uint8_t byte_command[16];
uint8_t length = this->command_queue_[this->command_queue_position_].length();
for (uint8_t i = 0; i < length; i++) {
size_t length = this->command_queue_[this->command_queue_position_].length();
if (length > sizeof(byte_command)) {
ESP_LOGE(TAG, "Command too long: %zu", length);
this->command_queue_[this->command_queue_position_].clear();
return false;
}
for (size_t i = 0; i < length; i++) {
byte_command[i] = (uint8_t) this->command_queue_[this->command_queue_position_].at(i);
}
this->state_ = STATE_COMMAND;
+2 -1
View File
@@ -34,7 +34,8 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) {
}
uint8_t PN7150I2C::write_nfcc(nfc::NciMessage &tx) {
if (this->write(tx.encode().data(), tx.encode().size()) == i2c::ERROR_OK) {
auto encoded = tx.encode();
if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) {
return nfc::STATUS_OK;
}
return nfc::STATUS_FAILED;
+2 -1
View File
@@ -34,7 +34,8 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) {
}
uint8_t PN7160I2C::write_nfcc(nfc::NciMessage &tx) {
if (this->write(tx.encode().data(), tx.encode().size()) == i2c::ERROR_OK) {
auto encoded = tx.encode();
if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) {
return nfc::STATUS_OK;
}
return nfc::STATUS_FAILED;
+8 -5
View File
@@ -146,16 +146,19 @@ void Rtttl::loop() {
}
#endif // USE_SPEAKER
// Align to note: most rtttl's out there does not add any space after the ',' separator but just in case
while (this->position_ < this->rtttl_.length()) {
char c = this->rtttl_[this->position_];
if (c != ',' && c != ' ')
break;
this->position_++;
}
if (this->position_ >= this->rtttl_.length()) {
this->finish_();
return;
}
// Align to note: most rtttl's out there does not add any space after the ',' separator but just in case
while (this->rtttl_[this->position_] == ',' || this->rtttl_[this->position_] == ' ') {
this->position_++;
}
// First, get note duration, if available
uint8_t note_denominator = this->get_integer_();
@@ -63,7 +63,8 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
switch (this->bits_per_pixel_) {
case 1:
this->width_bytes_ = (this->width_ % 8 == 0) ? (this->width_ / 8) : (this->width_ / 8 + 1);
this->width_bytes_ = (this->width_ + 7) / 8;
this->padding_bytes_ = (4 - (this->width_bytes_ % 4)) % 4;
break;
case 24:
this->width_bytes_ = this->width_ * 3;
@@ -92,15 +93,26 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
case 1: {
while (index < size) {
uint8_t current_byte = buffer[index];
bool end_of_row = false;
for (uint8_t i = 0; i < 8; i++) {
size_t x = (this->paint_index_ % static_cast<size_t>(this->width_)) + i;
size_t x = this->paint_index_ % static_cast<size_t>(this->width_);
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / static_cast<size_t>(this->width_));
Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF;
this->draw(x, y, 1, 1, c);
this->paint_index_++;
// End of pixel row: skip remaining bits in this byte
if (x + 1 >= static_cast<size_t>(this->width_)) {
end_of_row = true;
break;
}
}
this->paint_index_ += 8;
this->current_index_++;
index++;
// End of pixel row: skip row padding bytes (4-byte alignment)
if (end_of_row && this->padding_bytes_ > 0) {
index += this->padding_bytes_;
this->current_index_ += this->padding_bytes_;
}
}
break;
}
+13 -13
View File
@@ -21,11 +21,11 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP
const float temperature = temp_sign == 0 ? temp_val : -1 * temp_val;
const float humidity = data[0] * 0.5f;
const float pressure = (uint16_t(data[3] << 8) + uint16_t(data[4]) + 50000.0f) / 100.0f;
const float acceleration_x = (int16_t(data[5] << 8) + int16_t(data[6])) / 1000.0f;
const float acceleration_y = (int16_t(data[7] << 8) + int16_t(data[8])) / 1000.0f;
const float acceleration_z = (int16_t(data[9] << 8) + int16_t(data[10])) / 1000.0f;
const float battery_voltage = (uint16_t(data[11] << 8) + uint16_t(data[12])) / 1000.0f;
const float pressure = (encode_uint16(data[3], data[4]) + 50000.0f) / 100.0f;
const float acceleration_x = static_cast<int16_t>(encode_uint16(data[5], data[6])) / 1000.0f;
const float acceleration_y = static_cast<int16_t>(encode_uint16(data[7], data[8])) / 1000.0f;
const float acceleration_z = static_cast<int16_t>(encode_uint16(data[9], data[10])) / 1000.0f;
const float battery_voltage = encode_uint16(data[11], data[12]) / 1000.0f;
result.humidity = humidity;
result.temperature = temperature;
@@ -43,19 +43,19 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP
if (adv_data.size() != 24)
return false;
const float temperature = (int16_t(data[0] << 8) + int16_t(data[1])) * 0.005f;
const float humidity = (uint16_t(data[2] << 8) | uint16_t(data[3])) / 400.0f;
const float pressure = ((uint16_t(data[4] << 8) | uint16_t(data[5])) + 50000.0f) / 100.0f;
const float acceleration_x = (int16_t(data[6] << 8) + int16_t(data[7])) / 1000.0f;
const float acceleration_y = (int16_t(data[8] << 8) + int16_t(data[9])) / 1000.0f;
const float acceleration_z = (int16_t(data[10] << 8) + int16_t(data[11])) / 1000.0f;
const float temperature = static_cast<int16_t>(encode_uint16(data[0], data[1])) * 0.005f;
const float humidity = encode_uint16(data[2], data[3]) / 400.0f;
const float pressure = (encode_uint16(data[4], data[5]) + 50000.0f) / 100.0f;
const float acceleration_x = static_cast<int16_t>(encode_uint16(data[6], data[7])) / 1000.0f;
const float acceleration_y = static_cast<int16_t>(encode_uint16(data[8], data[9])) / 1000.0f;
const float acceleration_z = static_cast<int16_t>(encode_uint16(data[10], data[11])) / 1000.0f;
const uint16_t power_info = (uint16_t(data[12] << 8) | data[13]);
const uint16_t power_info = encode_uint16(data[12], data[13]);
const float battery_voltage = ((power_info >> 5) + 1600.0f) / 1000.0f;
const float tx_power = ((power_info & 0x1F) * 2.0f) - 40.0f;
const float movement_counter = float(data[14]);
const float measurement_sequence_number = float(uint16_t(data[15] << 8) | uint16_t(data[16]));
const float measurement_sequence_number = float(encode_uint16(data[15], data[16]));
result.temperature = data[0] == 0x7F && data[1] == 0xFF ? NAN : temperature;
result.humidity = data[2] == 0xFF && data[3] == 0xFF ? NAN : humidity;
+2 -2
View File
@@ -75,7 +75,7 @@ void RX8130Component::read_time() {
.second = bcd2dec(date[0] & 0x7f),
.minute = bcd2dec(date[1] & 0x7f),
.hour = bcd2dec(date[2] & 0x3f),
.day_of_week = bcd2dec(date[3] & 0x7f),
.day_of_week = static_cast<uint8_t>((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1),
.day_of_month = bcd2dec(date[4] & 0x3f),
.day_of_year = 1, // ignored by recalc_timestamp_utc(false)
.month = bcd2dec(date[5] & 0x1f),
@@ -103,7 +103,7 @@ void RX8130Component::write_time() {
buff[0] = dec2bcd(now.second);
buff[1] = dec2bcd(now.minute);
buff[2] = dec2bcd(now.hour);
buff[3] = dec2bcd(now.day_of_week);
buff[3] = 1 << (now.day_of_week - 1);
buff[4] = dec2bcd(now.day_of_month);
buff[5] = dec2bcd(now.month);
buff[6] = dec2bcd(now.year % 100);
@@ -199,7 +199,7 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c
}
break;
case DISTANCE_TYPE_BUFFER:
if (data[0] != 0) {
if (length >= 1 && data[0] != 0) {
if (this->distance_sensor_ != nullptr && length >= 8) {
uint32_t current_distance_int = encode_uint32(data[7], data[6], data[5], data[4]);
float distance_float;
+182 -156
View File
@@ -2,13 +2,16 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cmath>
#include <functional>
#include <memory>
namespace esphome::sen6x {
static const char *const TAG = "sen6x";
static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts
static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete
static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts
// Single numeric timeout ID — the chain is sequential so only one is active at a time.
static constexpr uint32_t TIMEOUT_POLL = 1;
static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202;
static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100;
static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014;
@@ -182,179 +185,202 @@ void SEN6XComponent::update() {
return;
}
uint16_t read_cmd;
uint8_t read_words;
set_read_command_and_words(this->sen6x_type_, read_cmd, read_words);
// Cancel any in-flight polling from a previous update() cycle.
this->cancel_timeout(TIMEOUT_POLL);
const uint8_t poll_retries = 24;
auto poll_ready = std::make_shared<std::function<void(uint8_t)>>();
*poll_ready = [this, poll_ready, read_cmd, read_words](uint8_t retries_left) {
const uint8_t attempt = static_cast<uint8_t>(poll_retries - retries_left + 1);
ESP_LOGV(TAG, "Data ready polling attempt %u", attempt);
set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_);
if (!this->write_command(SEN6X_CMD_GET_DATA_READY_STATUS)) {
// Polling uses chained timeouts to guarantee each I2C operation completes
// before the next begins. The flow is:
//
// poll_data_ready_()
// -> write_command (data ready status)
// -> timeout I2C_READ_DELAY
// -> read_data (check ready flag)
// -> if not ready: timeout POLL_INTERVAL -> poll_data_ready_() (retry)
// -> if ready: read_measurements_()
// -> write_command (read measurement)
// -> timeout I2C_READ_DELAY
// -> parse_and_publish_measurements_()
//
// All timeouts share a single ID (TIMEOUT_POLL) since only one is active
// at a time. cancel_timeout in update() stops any in-flight chain.
this->poll_retries_remaining_ = POLL_RETRIES;
this->poll_data_ready_();
}
void SEN6XComponent::poll_data_ready_() {
if (this->poll_retries_remaining_ == 0) {
this->status_set_warning();
ESP_LOGD(TAG, "Data not ready");
return;
}
ESP_LOGV(TAG, "Data ready polling attempt %u",
static_cast<unsigned>(POLL_RETRIES - this->poll_retries_remaining_ + 1));
this->poll_retries_remaining_--;
if (!this->write_command(SEN6X_CMD_GET_DATA_READY_STATUS)) {
this->status_set_warning();
ESP_LOGD(TAG, "write data ready status error (%d)", this->last_error_);
return;
}
this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() {
uint16_t raw_read_status;
if (!this->read_data(&raw_read_status, 1)) {
this->status_set_warning();
ESP_LOGD(TAG, "write data ready status error (%d)", this->last_error_);
ESP_LOGD(TAG, "read data ready status error (%d)", this->last_error_);
return;
}
this->set_timeout(20, [this, poll_ready, retries_left, read_cmd, read_words]() {
uint16_t raw_read_status;
if (!this->read_data(&raw_read_status, 1)) {
this->status_set_warning();
ESP_LOGD(TAG, "read data ready status error (%d)", this->last_error_);
return;
}
if ((raw_read_status & 0x0001) == 0) {
// Not ready yet; schedule next attempt after POLL_INTERVAL.
this->set_timeout(TIMEOUT_POLL, POLL_INTERVAL, [this]() { this->poll_data_ready_(); });
return;
}
if ((raw_read_status & 0x0001) == 0) {
if (retries_left == 0) {
this->status_set_warning();
ESP_LOGD(TAG, "Data not ready");
return;
}
this->set_timeout(50, [poll_ready, retries_left]() { (*poll_ready)(retries_left - 1); });
return;
}
this->read_measurements_();
});
}
if (!this->write_command(read_cmd)) {
this->status_set_warning();
ESP_LOGD(TAG, "Read measurement failed (%d)", this->last_error_);
return;
}
void SEN6XComponent::read_measurements_() {
if (!this->write_command(this->read_cmd_)) {
this->status_set_warning();
ESP_LOGD(TAG, "Read measurement failed (%d)", this->last_error_);
return;
}
this->set_timeout(20, [this, read_words]() {
uint16_t measurements[10];
this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_measurements_(); });
}
if (!this->read_data(measurements, read_words)) {
this->status_set_warning();
ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_);
return;
}
int8_t voc_index = -1;
int8_t nox_index = -1;
int8_t hcho_index = -1;
int8_t co2_index = -1;
bool co2_uint16 = false;
switch (this->sen6x_type_) {
case SEN62:
break;
case SEN63C:
co2_index = 6;
break;
case SEN65:
voc_index = 6;
nox_index = 7;
break;
case SEN66:
voc_index = 6;
nox_index = 7;
co2_index = 8;
co2_uint16 = true;
break;
case SEN68:
voc_index = 6;
nox_index = 7;
hcho_index = 8;
break;
case SEN69C:
voc_index = 6;
nox_index = 7;
hcho_index = 8;
co2_index = 9;
break;
default:
break;
}
void SEN6XComponent::parse_and_publish_measurements_() {
uint16_t measurements[10];
float pm_1_0 = measurements[0] / 10.0f;
if (measurements[0] == 0xFFFF)
pm_1_0 = NAN;
float pm_2_5 = measurements[1] / 10.0f;
if (measurements[1] == 0xFFFF)
pm_2_5 = NAN;
float pm_4_0 = measurements[2] / 10.0f;
if (measurements[2] == 0xFFFF)
pm_4_0 = NAN;
float pm_10_0 = measurements[3] / 10.0f;
if (measurements[3] == 0xFFFF)
pm_10_0 = NAN;
float humidity = static_cast<int16_t>(measurements[4]) / 100.0f;
if (measurements[4] == 0x7FFF)
humidity = NAN;
float temperature = static_cast<int16_t>(measurements[5]) / 200.0f;
if (measurements[5] == 0x7FFF)
temperature = NAN;
if (!this->read_data(measurements, this->read_words_)) {
this->status_set_warning();
ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_);
return;
}
int8_t voc_index = -1;
int8_t nox_index = -1;
int8_t hcho_index = -1;
int8_t co2_index = -1;
bool co2_uint16 = false;
switch (this->sen6x_type_) {
case SEN62:
break;
case SEN63C:
co2_index = 6;
break;
case SEN65:
voc_index = 6;
nox_index = 7;
break;
case SEN66:
voc_index = 6;
nox_index = 7;
co2_index = 8;
co2_uint16 = true;
break;
case SEN68:
voc_index = 6;
nox_index = 7;
hcho_index = 8;
break;
case SEN69C:
voc_index = 6;
nox_index = 7;
hcho_index = 8;
co2_index = 9;
break;
default:
break;
}
float voc = NAN;
float nox = NAN;
float hcho = NAN;
float co2 = NAN;
float pm_1_0 = measurements[0] / 10.0f;
if (measurements[0] == 0xFFFF)
pm_1_0 = NAN;
float pm_2_5 = measurements[1] / 10.0f;
if (measurements[1] == 0xFFFF)
pm_2_5 = NAN;
float pm_4_0 = measurements[2] / 10.0f;
if (measurements[2] == 0xFFFF)
pm_4_0 = NAN;
float pm_10_0 = measurements[3] / 10.0f;
if (measurements[3] == 0xFFFF)
pm_10_0 = NAN;
float humidity = static_cast<int16_t>(measurements[4]) / 100.0f;
if (measurements[4] == 0x7FFF)
humidity = NAN;
float temperature = static_cast<int16_t>(measurements[5]) / 200.0f;
if (measurements[5] == 0x7FFF)
temperature = NAN;
if (voc_index >= 0) {
voc = static_cast<int16_t>(measurements[voc_index]) / 10.0f;
if (measurements[voc_index] == 0x7FFF)
voc = NAN;
}
if (nox_index >= 0) {
nox = static_cast<int16_t>(measurements[nox_index]) / 10.0f;
if (measurements[nox_index] == 0x7FFF)
nox = NAN;
}
float voc = NAN;
float nox = NAN;
float hcho = NAN;
float co2 = NAN;
if (hcho_index >= 0) {
const uint16_t hcho_raw = measurements[hcho_index];
hcho = hcho_raw / 10.0f;
if (hcho_raw == 0xFFFF)
hcho = NAN;
}
if (voc_index >= 0) {
voc = static_cast<int16_t>(measurements[voc_index]) / 10.0f;
if (measurements[voc_index] == 0x7FFF)
voc = NAN;
}
if (nox_index >= 0) {
nox = static_cast<int16_t>(measurements[nox_index]) / 10.0f;
if (measurements[nox_index] == 0x7FFF)
nox = NAN;
}
if (co2_index >= 0) {
if (co2_uint16) {
const uint16_t co2_raw = measurements[co2_index];
co2 = static_cast<float>(co2_raw);
if (co2_raw == 0xFFFF)
co2 = NAN;
} else {
const int16_t co2_raw = static_cast<int16_t>(measurements[co2_index]);
co2 = static_cast<float>(co2_raw);
if (co2_raw == 0x7FFF)
co2 = NAN;
}
}
if (hcho_index >= 0) {
const uint16_t hcho_raw = measurements[hcho_index];
hcho = hcho_raw / 10.0f;
if (hcho_raw == 0xFFFF)
hcho = NAN;
}
if (!this->startup_complete_) {
ESP_LOGD(TAG, "Startup delay, ignoring values");
this->status_clear_warning();
return;
}
if (co2_index >= 0) {
if (co2_uint16) {
const uint16_t co2_raw = measurements[co2_index];
co2 = static_cast<float>(co2_raw);
if (co2_raw == 0xFFFF)
co2 = NAN;
} else {
const int16_t co2_raw = static_cast<int16_t>(measurements[co2_index]);
co2 = static_cast<float>(co2_raw);
if (co2_raw == 0x7FFF)
co2 = NAN;
}
}
if (this->pm_1_0_sensor_ != nullptr)
this->pm_1_0_sensor_->publish_state(pm_1_0);
if (this->pm_2_5_sensor_ != nullptr)
this->pm_2_5_sensor_->publish_state(pm_2_5);
if (this->pm_4_0_sensor_ != nullptr)
this->pm_4_0_sensor_->publish_state(pm_4_0);
if (this->pm_10_0_sensor_ != nullptr)
this->pm_10_0_sensor_->publish_state(pm_10_0);
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temperature);
if (this->humidity_sensor_ != nullptr)
this->humidity_sensor_->publish_state(humidity);
if (this->voc_sensor_ != nullptr)
this->voc_sensor_->publish_state(voc);
if (this->nox_sensor_ != nullptr)
this->nox_sensor_->publish_state(nox);
if (this->hcho_sensor_ != nullptr)
this->hcho_sensor_->publish_state(hcho);
if (this->co2_sensor_ != nullptr)
this->co2_sensor_->publish_state(co2);
if (!this->startup_complete_) {
ESP_LOGD(TAG, "Startup delay, ignoring values");
this->status_clear_warning();
return;
}
this->status_clear_warning();
});
});
};
if (this->pm_1_0_sensor_ != nullptr)
this->pm_1_0_sensor_->publish_state(pm_1_0);
if (this->pm_2_5_sensor_ != nullptr)
this->pm_2_5_sensor_->publish_state(pm_2_5);
if (this->pm_4_0_sensor_ != nullptr)
this->pm_4_0_sensor_->publish_state(pm_4_0);
if (this->pm_10_0_sensor_ != nullptr)
this->pm_10_0_sensor_->publish_state(pm_10_0);
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temperature);
if (this->humidity_sensor_ != nullptr)
this->humidity_sensor_->publish_state(humidity);
if (this->voc_sensor_ != nullptr)
this->voc_sensor_->publish_state(voc);
if (this->nox_sensor_ != nullptr)
this->nox_sensor_->publish_state(nox);
if (this->hcho_sensor_ != nullptr)
this->hcho_sensor_->publish_state(hcho);
if (this->co2_sensor_ != nullptr)
this->co2_sensor_->publish_state(co2);
(*poll_ready)(poll_retries);
this->status_clear_warning();
}
SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) {
+6
View File
@@ -30,13 +30,19 @@ class SEN6XComponent : public PollingComponent, public sensirion_common::Sensiri
protected:
Sen6xType infer_type_from_product_name_(const std::string &product_name);
void poll_data_ready_();
void read_measurements_();
void parse_and_publish_measurements_();
bool initialized_{false};
std::string product_name_;
Sen6xType sen6x_type_{UNKNOWN};
std::string serial_number_;
uint16_t read_cmd_{0};
uint8_t firmware_version_major_{0};
uint8_t firmware_version_minor_{0};
uint8_t poll_retries_remaining_{0};
uint8_t read_words_{0};
bool startup_complete_{false};
};
@@ -74,6 +74,9 @@ class SerialProxy : public uart::UARTDevice, public Component {
/// @param data_size Number of data bits (5-8)
void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size);
/// Get the currently subscribed API connection (nullptr if none)
api::APIConnection *get_api_connection() { return this->api_connection_; }
/// Handle a subscribe/unsubscribe request from an API client
void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type);
+20 -5
View File
@@ -14,11 +14,26 @@ void SMT100Component::update() {
void SMT100Component::loop() {
while (this->available() != 0) {
if (this->readline_(this->read(), this->readline_buffer_, MAX_LINE_LENGTH) > 0) {
int counts = (int) strtol((strtok(this->readline_buffer_, ",")), nullptr, 10);
float permittivity = (float) strtod((strtok(nullptr, ",")), nullptr);
float moisture = (float) strtod((strtok(nullptr, ",")), nullptr);
float temperature = (float) strtod((strtok(nullptr, ",")), nullptr);
float voltage = (float) strtod((strtok(nullptr, ",")), nullptr);
char *token = strtok(this->readline_buffer_, ",");
if (!token)
continue;
int counts = (int) strtol(token, nullptr, 10);
token = strtok(nullptr, ",");
if (!token)
continue;
float permittivity = (float) strtod(token, nullptr);
token = strtok(nullptr, ",");
if (!token)
continue;
float moisture = (float) strtod(token, nullptr);
token = strtok(nullptr, ",");
if (!token)
continue;
float temperature = (float) strtod(token, nullptr);
token = strtok(nullptr, ",");
if (!token)
continue;
float voltage = (float) strtod(token, nullptr);
if (this->counts_sensor_ != nullptr) {
counts_sensor_->publish_state(counts);
+4 -2
View File
@@ -36,11 +36,13 @@ void ST7701S::setup() {
config.de_gpio_num = this->de_pin_->get_pin();
config.pclk_gpio_num = this->pclk_pin_->get_pin();
esp_err_t err = esp_lcd_new_rgb_panel(&config, &this->handle_);
ESP_ERROR_CHECK(esp_lcd_panel_reset(this->handle_));
ESP_ERROR_CHECK(esp_lcd_panel_init(this->handle_));
if (err != ESP_OK) {
esph_log_e(TAG, "lcd_new_rgb_panel failed: %s", esp_err_to_name(err));
this->mark_failed();
return;
}
ESP_ERROR_CHECK(esp_lcd_panel_reset(this->handle_));
ESP_ERROR_CHECK(esp_lcd_panel_init(this->handle_));
}
void ST7701S::loop() {
@@ -99,7 +99,7 @@ struct MessageHeader {
// payload_size returns the amount of payload bytes to be read from the uart
// buffer after reading the header.
uint32_t payload_size() { return this->len - sizeof(this->type); }
uint32_t payload_size() { return this->len > sizeof(this->type) ? this->len - sizeof(this->type) : 0; }
} __attribute__((packed));
// StatusType denotes which 'page' of information needs to be retrieved.
+3 -3
View File
@@ -951,10 +951,10 @@ void ToshibaClimate::transmit_ras_2819t_() {
}
uint8_t ToshibaClimate::is_valid_rac_pt1411hwru_header_(const uint8_t *message) {
const std::vector<uint8_t> header{RAC_PT1411HWRU_MESSAGE_HEADER0, RAC_PT1411HWRU_CS_HEADER,
RAC_PT1411HWRU_SWING_HEADER};
static constexpr uint8_t HEADERS[] = {RAC_PT1411HWRU_MESSAGE_HEADER0, RAC_PT1411HWRU_CS_HEADER,
RAC_PT1411HWRU_SWING_HEADER};
for (auto i : header) {
for (auto i : HEADERS) {
if ((message[0] == i) && (message[1] == static_cast<uint8_t>(~i)))
return i;
}
@@ -496,6 +496,11 @@ bool USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u
ESP_LOGE(TAG, "Too many requests queued");
return false;
}
if (length > trq->transfer->data_buffer_size) {
ESP_LOGE(TAG, "transfer_in: data length %u exceeds buffer size %u", length, trq->transfer->data_buffer_size);
this->release_trq(trq);
return false;
}
trq->callback = callback;
trq->transfer->callback = transfer_callback;
trq->transfer->bEndpointAddress = ep_address | USB_DIR_IN;
+1 -1
View File
@@ -65,7 +65,7 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
}
for (uint8_t i = 0; i != config_desc->bNumInterfaces; i++) {
auto data_desc = usb_parse_interface_descriptor(config_desc, 0, 0, &conf_offset);
auto data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset);
if (!data_desc) {
ESP_LOGE(TAG, "data_desc: usb_parse_interface_descriptor failed");
break;
+5
View File
@@ -106,10 +106,15 @@ std::vector<CdcEps> USBUartTypeCdcAcm::parse_descriptors(usb_device_handle_t dev
}
void RingBuffer::push(uint8_t item) {
if (this->get_free_space() == 0)
return;
this->buffer_[this->insert_pos_] = item;
this->insert_pos_ = (this->insert_pos_ + 1) % this->buffer_size_;
}
void RingBuffer::push(const uint8_t *data, size_t len) {
size_t free = this->get_free_space();
if (len > free)
len = free;
for (size_t i = 0; i != len; i++) {
this->buffer_[this->insert_pos_] = *data++;
this->insert_pos_ = (this->insert_pos_ + 1) % this->buffer_size_;
@@ -87,9 +87,9 @@ void VL53L0XSensor::setup() {
reg(0x94) = 0x6B;
reg(0x83) = 0x00;
this->timeout_start_us_ = micros();
uint32_t timeout_start_us = micros();
while (reg(0x83).get() == 0x00) {
if (this->timeout_us_ > 0 && ((uint16_t) (micros() - this->timeout_start_us_) > this->timeout_us_)) {
if (this->timeout_us_ > 0 && (micros() - timeout_start_us > this->timeout_us_)) {
ESP_LOGE(TAG, "'%s' - setup timeout", this->name_.c_str());
this->mark_failed();
return;
+1 -2
View File
@@ -64,8 +64,7 @@ class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c
bool waiting_for_interrupt_{false};
uint8_t stop_variable_;
uint16_t timeout_start_us_;
uint16_t timeout_us_{};
uint32_t timeout_us_{};
static std::list<VL53L0XSensor *> vl53_sensors; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool enable_pin_setup_complete; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -639,8 +639,6 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const {
return WiFiSTAConnectStatus::IDLE;
}
bool WiFiComponent::wifi_scan_start_(bool passive) {
static bool first_scan = false;
// enable STA
if (!this->wifi_mode_(true, {}))
return false;
@@ -657,23 +655,13 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
config.show_hidden = 1;
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0)
config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE;
if (first_scan) {
if (passive) {
config.scan_time.passive = 200;
} else {
config.scan_time.active.min = 100;
config.scan_time.active.max = 200;
}
if (passive) {
config.scan_time.passive = 500;
} else {
if (passive) {
config.scan_time.passive = 500;
} else {
config.scan_time.active.min = 400;
config.scan_time.active.max = 500;
}
config.scan_time.active.min = 400;
config.scan_time.active.max = 500;
}
#endif
first_scan = false;
bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback);
if (!ret) {
ESP_LOGV(TAG, "wifi_station_scan failed");
+1 -1
View File
@@ -56,7 +56,7 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_K_VALUE, default=4096): cv.uint16_t,
cv.Optional(CONF_K_VALUE, default=4096): cv.int_range(min=1, max=65535),
}
)
.extend(cv.polling_component_schema("60s"))
+15
View File
@@ -615,6 +615,10 @@ class EsphomeCore:
self.address_cache: AddressCache | None = None
# Cached config hash (computed lazily)
self._config_hash: int | None = None
# True if compiling for C++ unit tests
self.cpp_testing = False
# Allowlist of components whose to_code should run during C++ testing
self.cpp_testing_codegen: set[str] = set()
def reset(self):
from esphome.pins import PIN_SCHEMA_REGISTRY
@@ -644,6 +648,8 @@ class EsphomeCore:
self.current_component = None
self.address_cache = None
self._config_hash = None
self.cpp_testing = False
self.cpp_testing_codegen = set()
PIN_SCHEMA_REGISTRY.reset()
@contextmanager
@@ -996,6 +1002,15 @@ class EsphomeCore:
"""
self.platform_counts[platform_name] += 1
def testing_ensure_platform_registered(self, platform_name: str) -> None:
"""Ensure a platform has at least one entity registered for testing.
Used during C++ test builds to guarantee USE_* defines are emitted
without needing a real component variable.
"""
if not self.platform_counts[platform_name]:
self.platform_counts[platform_name] = 1
def register_controller(self) -> None:
"""Track registration of a Controller for ControllerRegistry StaticVector sizing."""
controller_count = self.data.setdefault(KEY_CONTROLLER_REGISTRY_COUNT, 0)
+5
View File
@@ -71,6 +71,11 @@ class ComponentManifest:
@property
def to_code(self) -> Callable[[Any], None] | None:
if CORE.cpp_testing:
# During C++ testing, only run to_code for allowlisted components
name = self.module.__package__.rsplit(".", 1)[-1]
if name not in CORE.cpp_testing_codegen:
return None
return getattr(self.module, "to_code", None)
@property
+22 -5
View File
@@ -1,5 +1,6 @@
import collections
from collections.abc import Callable
from dataclasses import dataclass
import io
import logging
from pathlib import Path
@@ -401,11 +402,19 @@ def get_picotool_path(cc_path: str) -> Path | None:
return None
def detect_rp2040_bootsel(picotool_path: str | Path) -> int:
@dataclass
class BootselResult:
"""Result of RP2040 BOOTSEL detection."""
device_count: int
permission_error: bool = False
def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult:
"""Detect RP2040/RP2350 devices in BOOTSEL mode using picotool.
Returns the number of devices found (by counting 'type:' lines in output),
matching PlatformIO's detection approach.
Returns a BootselResult with the number of devices found (by counting
'type:' lines in output), and whether a permission error was detected.
"""
try:
result = subprocess.run(
@@ -414,9 +423,17 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> int:
timeout=10,
check=False,
)
return result.stdout.count(b"type:")
device_count = result.stdout.count(b"type:")
if device_count > 0:
return BootselResult(device_count)
# Check stderr for permission issues — picotool can see the device
# on the USB bus but can't connect without proper permissions
combined = result.stderr + result.stdout
if b"unable to connect" in combined or b"LIBUSB_ERROR_ACCESS" in combined:
return BootselResult(0, permission_error=True)
return BootselResult(0)
except (OSError, subprocess.TimeoutExpired):
return 0
return BootselResult(0)
def get_esp32_arduino_flash_error_help() -> str | None:
+1 -1
View File
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools==82.0.0", "wheel>=0.43,<0.47"]
requires = ["setuptools==82.0.1", "wheel>=0.43,<0.47"]
build-backend = "setuptools.build_meta"
[project]
+95 -58
View File
@@ -10,9 +10,10 @@ from helpers import get_all_components, get_all_dependencies, root_path
from esphome.__main__ import command_compile, parse_args
from esphome.config import validate_config
from esphome.const import CONF_PLATFORM
from esphome.core import CORE
from esphome.loader import get_component
from esphome.platformio_api import get_idedata
from esphome.yaml_util import load_yaml
# This must coincide with the version in /platformio.ini
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
@@ -20,6 +21,13 @@ PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
# Path to /tests/components
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
# Components whose to_code should run during C++ test builds.
# Most components don't need code generation for tests; only these
# essential ones (platform setup, logging, core config) are needed.
# Note: "core" is the esphome core config module (esphome/core/config.py),
# which registers under package name "core" not "esphome".
CPP_TESTING_CODEGEN_COMPONENTS = {"core", "host", "logger"}
def hash_components(components: list[str]) -> str:
key = ",".join(components)
@@ -30,12 +38,14 @@ def filter_components_without_tests(components: list[str]) -> list[str]:
"""Filter out components that do not have a corresponding test file.
This is done by checking if the component's directory contains at
least a .cpp file.
least a .cpp or .h file.
"""
filtered_components: list[str] = []
for component in components:
test_dir = COMPONENTS_TESTS_DIR / component
if test_dir.is_dir() and any(test_dir.glob("*.cpp")):
if test_dir.is_dir() and (
any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h"))
):
filtered_components.append(component)
else:
print(
@@ -45,38 +55,6 @@ def filter_components_without_tests(components: list[str]) -> list[str]:
return filtered_components
# Name of optional per-component YAML config merged into the test build
# before validation so that platform defines (USE_SENSOR, etc.) are generated.
CPP_TEST_CONFIG_FILE = "cpp_test.yaml"
def load_component_test_configs(components: list[str]) -> dict:
"""Load cpp_test.yaml files from test component directories.
These configs are merged into the base test config *before* validation
so that entity registration runs during code generation, which causes
the corresponding USE_* defines to be emitted.
"""
merged: dict = {}
for component in components:
config_file = COMPONENTS_TESTS_DIR / component / CPP_TEST_CONFIG_FILE
if not config_file.exists():
continue
component_config = load_yaml(config_file)
if not component_config:
continue
for key, value in component_config.items():
if (
key in merged
and isinstance(merged[key], list)
and isinstance(value, list)
):
merged[key].extend(value)
else:
merged[key] = value
return merged
def create_test_config(config_name: str, includes: list[str]) -> dict:
"""Create ESPHome test configuration for C++ unit tests.
@@ -114,11 +92,52 @@ def create_test_config(config_name: str, includes: list[str]) -> dict:
}
def get_platform_components(components: list[str]) -> list[str]:
"""Discover platform sub-components referenced by test directory structure.
For each component being tested, any sub-directory named after a platform
domain (e.g. ``sensor``, ``binary_sensor``) is treated as a request to
include that ``<domain>.<component>`` platform in the build. The sub-
directory must name a valid platform domain; anything else raises an error
so that typos are caught early.
Returns:
List of ``"domain.component"`` strings, one per discovered sub-directory.
"""
platform_components: list[str] = []
for component in components:
test_dir = COMPONENTS_TESTS_DIR / component
if not test_dir.is_dir():
continue
# Each sub-directory name is expected to be a platform domain
# (e.g. tests/components/bthome/sensor/ → sensor.bthome).
for domain_dir in test_dir.iterdir():
if not domain_dir.is_dir():
continue
domain = domain_dir.name
domain_module = get_component(domain)
if domain_module is None or not domain_module.is_platform_component:
raise ValueError(
f"Component tests for '{component}' reference non-existing or invalid domain '{domain}'"
f" in its directory structure. See ({COMPONENTS_TESTS_DIR / component / domain})."
)
platform_components.append(f"{domain}.{component}")
return platform_components
# Exit codes for run_tests
EXIT_OK = 0
EXIT_SKIPPED = 1
EXIT_COMPILE_ERROR = 2
EXIT_CONFIG_ERROR = 3
EXIT_NO_EXECUTABLE = 4
def run_tests(selected_components: list[str]) -> int:
# Skip tests on Windows
if os.name == "nt":
print("Skipping esphome tests on Windows", file=sys.stderr)
return 1
return EXIT_SKIPPED
# Remove components that do not have tests
components = filter_components_without_tests(selected_components)
@@ -128,45 +147,63 @@ def run_tests(selected_components: list[str]) -> int:
"No components specified or no tests found for the specified components.",
file=sys.stderr,
)
return 0
return EXIT_OK
components = sorted(components)
# Obtain possible dependencies for the requested components.
# Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag,
# which causes core/time.h to include components/time/posix_tz.h.
components_with_dependencies = sorted(
get_all_dependencies(set(components) | {"time"})
)
# Build a list of include folders, one folder per component containing tests.
# A special replacement main.cpp is located in /tests/components/main.cpp
# Build a list of include folders relative to COMPONENTS_TESTS_DIR. These folders will
# be added along with their subfolders.
# "main.cpp" is a special entry that points to /tests/components/main.cpp,
# which provides a custom test runner entry-point replacing the default one.
# Each remaining entry is a component folder whose *.cpp files are compiled.
includes: list[str] = ["main.cpp"] + components
# Obtain a list of platform components to be tested:
try:
platform_components = get_platform_components(components)
except ValueError as e:
print(f"Error obtaining platform components: {e}")
return EXIT_CONFIG_ERROR
components = sorted(components + platform_components)
# Create a unique name for this config based on the actual components being tested
# to maximize cache during testing
config_name: str = "cpptests-" + hash_components(components)
config = create_test_config(config_name, includes)
# Obtain possible dependencies for the requested components.
# Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag,
# which causes core/time.h to include components/time/posix_tz.h.
components_with_dependencies: list[str] = sorted(
get_all_dependencies(set(components) | {"time"}, cpp_testing=True)
)
# Merge component-specific test configs (e.g. sensor instances) before
# validation so that entity registration and USE_* defines work.
extra_config = load_component_test_configs(components)
config.update(extra_config)
config = create_test_config(config_name, includes)
CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml"
CORE.dashboard = None
CORE.cpp_testing = True
CORE.cpp_testing_codegen = CPP_TESTING_CODEGEN_COMPONENTS
# Validate config will expand the above with defaults:
config = validate_config(config, {})
# Add all components and dependencies to the base configuration after validation, so their files
# are added to the build. Use setdefault to avoid overwriting entries that were
# already validated (e.g. sensor instances from cpp_test.yaml).
for key in components_with_dependencies:
config.setdefault(key, {})
# are added to the build.
for component_name in components_with_dependencies:
if "." in component_name:
# Format is always "domain.component" (exactly one dot),
# as produced by get_platform_components().
domain, component = component_name.split(".", maxsplit=1)
domain_list = config.setdefault(domain, [])
CORE.testing_ensure_platform_registered(domain)
domain_list.append({CONF_PLATFORM: component})
else:
config.setdefault(component_name, [])
print(f"Testing components: {', '.join(components)}")
dependencies = set(components_with_dependencies) - set(components)
deps_str = ", ".join(dependencies) if dependencies else "None"
print(f"Testing components: {', '.join(components)}. Dependencies: {deps_str}")
CORE.config = config
args = parse_args(["program", "compile", str(CORE.config_path)])
try:
@@ -179,13 +216,13 @@ def run_tests(selected_components: list[str]) -> int:
print(
f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}"
)
return 2
return EXIT_COMPILE_ERROR
# After a successful compilation, locate the executable and run it:
idedata = get_idedata(config)
if idedata is None:
print("Cannot find executable")
return 1
return EXIT_NO_EXECUTABLE
program_path: str = idedata.raw["prog_path"]
run_cmd: list[str] = [program_path]
+17 -4
View File
@@ -15,6 +15,8 @@ from typing import Any
import colorama
from esphome.loader import get_platform
root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", "..")))
basepath = os.path.join(root_path, "esphome")
temp_folder = os.path.join(root_path, ".temp")
@@ -624,11 +626,15 @@ def get_usable_cpu_count() -> int:
)
def get_all_dependencies(component_names: set[str]) -> set[str]:
def get_all_dependencies(
component_names: set[str], cpp_testing: bool = False
) -> set[str]:
"""Get all dependencies for a set of components.
Args:
component_names: Set of component names to get dependencies for
cpp_testing: If True, set CORE.cpp_testing so AUTO_LOAD callables that
conditionally include testing-only dependencies work correctly
Returns:
Set of all components including dependencies and auto-loaded components
@@ -646,6 +652,7 @@ def get_all_dependencies(component_names: set[str]) -> set[str]:
# Reset CORE to ensure clean state
CORE.reset()
CORE.cpp_testing = cpp_testing
# Set up fake config path for component loading
root = Path(__file__).parent.parent
@@ -660,7 +667,11 @@ def get_all_dependencies(component_names: set[str]) -> set[str]:
new_components: set[str] = set()
for comp_name in all_components:
comp = get_component(comp_name)
if "." in comp_name:
domain, platform = comp_name.split(".", maxsplit=1)
comp = get_platform(domain, platform)
else:
comp = get_component(comp_name)
if not comp:
continue
@@ -705,8 +716,10 @@ def get_components_from_integration_fixtures() -> set[str]:
if not config:
continue
# Add all top-level component keys
components.update(config.keys())
# Add all top-level component keys (skip YAML anchor keys starting with '.')
components.update(
k for k in config if isinstance(k, str) and not k.startswith(".")
)
# Add platform components (e.g., output.template)
for value in config.values():
+13
View File
@@ -7,10 +7,23 @@
testing binaries that combine many components. By convention, this unique namespace is `esphome::component::testing`
(where "component" is the component under test), for example: `esphome::uart::testing`.
### Platform components
For components that expose to a platform component, create a folder under your component test folder with the platform component name, e.g. `binary_sensor` and
include the relevant `.cpp` and `.h` test files there.
### Override component code generation for testing
When generating code for testing, ESPHome won't invoke the component's `to_code` function, since most components do not
need to generate configuration code for testing.
If you do need to generate code to for example configure compilation flags or add libraries,
add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`.
## Running component unit tests
(from the repository root)
```bash
./script/cpp_unit_test.py component1 component2 ...
```
@@ -0,0 +1,77 @@
#include "../common.h"
namespace esphome::packet_transport::testing {
TEST(PacketTransportBinarySensorTest, AddBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.add_binary_sensor("motion", &bs);
ASSERT_EQ(transport.binary_sensors_.size(), 1u);
EXPECT_STREQ(transport.binary_sensors_[0].id, "motion");
EXPECT_EQ(transport.binary_sensors_[0].sensor, &bs);
}
TEST(PacketTransportBinarySensorTest, AddRemoteBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.add_remote_binary_sensor("host1", "remote_motion", &bs);
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.remote_binary_sensors_["host1"]["remote_motion"], &bs);
}
TEST(PacketTransportBinarySensorTest, UnencryptedBinarySensorRoundTrip) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
binary_sensor::BinarySensor local_bs;
local_bs.state = true;
encoder.add_binary_sensor("motion", &local_bs);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
binary_sensor::BinarySensor remote_bs;
decoder.add_remote_binary_sensor("sender", "motion", &remote_bs);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_TRUE(remote_bs.state);
}
TEST(PacketTransportBinarySensorTest, MultipleSensorsRoundTrip) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor s1, s2;
s1.state = 10.0f;
s2.state = 20.0f;
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
binary_sensor::BinarySensor bs1;
bs1.state = true;
encoder.add_binary_sensor("bs1", &bs1);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor rs1, rs2;
binary_sensor::BinarySensor rbs1;
rs1.state = -999.0f;
rs2.state = -999.0f;
decoder.add_remote_sensor("sender", "s1", &rs1);
decoder.add_remote_sensor("sender", "s2", &rs2);
decoder.add_remote_binary_sensor("sender", "bs1", &rbs1);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(rs1.state, 10.0f);
EXPECT_FLOAT_EQ(rs2.state, 20.0f);
EXPECT_TRUE(rbs1.state);
}
} // namespace esphome::packet_transport::testing
@@ -1,11 +0,0 @@
# Extra component configuration required by C++ unit tests.
# Loaded by cpp_unit_test.py and merged into the test build config
# before validation, so that platform defines (USE_SENSOR, etc.) are generated.
sensor:
- platform: template
id: test_cpp_sensor
binary_sensor:
- platform: template
id: test_cpp_binary_sensor
@@ -65,198 +65,6 @@ TEST(PacketTransportTest, SetProviderEncryption) {
EXPECT_EQ(transport.providers_["host1"].encryption_key, key);
}
// --- Sensor management (requires USE_SENSOR / USE_BINARY_SENSOR) ---
#ifdef USE_SENSOR
TEST(PacketTransportTest, AddSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.add_sensor("temp", &s);
ASSERT_EQ(transport.sensors_.size(), 1u);
EXPECT_STREQ(transport.sensors_[0].id, "temp");
EXPECT_EQ(transport.sensors_[0].sensor, &s);
EXPECT_TRUE(transport.sensors_[0].updated);
}
TEST(PacketTransportTest, AddRemoteSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.add_remote_sensor("host1", "remote_temp", &s);
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.remote_sensors_["host1"]["remote_temp"], &s);
}
#endif
#ifdef USE_BINARY_SENSOR
TEST(PacketTransportTest, AddBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.add_binary_sensor("motion", &bs);
ASSERT_EQ(transport.binary_sensors_.size(), 1u);
EXPECT_STREQ(transport.binary_sensors_[0].id, "motion");
EXPECT_EQ(transport.binary_sensors_[0].sensor, &bs);
}
TEST(PacketTransportTest, AddRemoteBinarySensor) {
TestablePacketTransport transport;
binary_sensor::BinarySensor bs;
transport.add_remote_binary_sensor("host1", "remote_motion", &bs);
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.remote_binary_sensors_["host1"]["remote_motion"], &bs);
}
#endif
// --- Unencrypted round-trip tests (require USE_SENSOR / USE_BINARY_SENSOR) ---
#ifdef USE_SENSOR
TEST(PacketTransportTest, UnencryptedSensorRoundTrip) {
// Encoder
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor local_sensor;
local_sensor.state = 42.5f;
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
// Decoder
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f; // sentinel
decoder.add_remote_sensor("sender", "temp", &remote_sensor);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 42.5f);
}
#endif
#ifdef USE_BINARY_SENSOR
TEST(PacketTransportTest, UnencryptedBinarySensorRoundTrip) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
binary_sensor::BinarySensor local_bs;
local_bs.state = true;
encoder.add_binary_sensor("motion", &local_bs);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
binary_sensor::BinarySensor remote_bs;
decoder.add_remote_binary_sensor("sender", "motion", &remote_bs);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_TRUE(remote_bs.state);
}
#endif
#if defined(USE_SENSOR) && defined(USE_BINARY_SENSOR)
TEST(PacketTransportTest, MultipleSensorsRoundTrip) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor s1, s2;
s1.state = 10.0f;
s2.state = 20.0f;
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
binary_sensor::BinarySensor bs1;
bs1.state = true;
encoder.add_binary_sensor("bs1", &bs1);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor rs1, rs2;
binary_sensor::BinarySensor rbs1;
rs1.state = -999.0f;
rs2.state = -999.0f;
decoder.add_remote_sensor("sender", "s1", &rs1);
decoder.add_remote_sensor("sender", "s2", &rs2);
decoder.add_remote_binary_sensor("sender", "bs1", &rbs1);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(rs1.state, 10.0f);
EXPECT_FLOAT_EQ(rs2.state, 20.0f);
EXPECT_TRUE(rbs1.state);
}
#endif
// --- Encrypted round-trip ---
#ifdef USE_SENSOR
TEST(PacketTransportTest, EncryptedSensorRoundTrip) {
std::vector<uint8_t> key(32);
for (int i = 0; i < 32; i++)
key[i] = i;
TestablePacketTransport encoder;
encoder.init_for_test("sender");
encoder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 99.9f;
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
decoder.add_remote_sensor("sender", "temp", &remote_sensor);
decoder.set_provider_encryption("sender", key);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 99.9f);
}
// --- Selective send ---
TEST(PacketTransportTest, SendDataOnlyUpdated) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor s1, s2;
s1.state = 1.0f;
s2.state = 2.0f;
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
// Mark s1 as not updated, only s2 as updated
encoder.sensors_[0].updated = false;
encoder.sensors_[1].updated = true;
encoder.send_data_(false);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor rs1, rs2;
rs1.state = -999.0f;
rs2.state = -999.0f;
decoder.add_remote_sensor("sender", "s1", &rs1);
decoder.add_remote_sensor("sender", "s2", &rs2);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(rs1.state, -999.0f); // not updated, not sent
EXPECT_FLOAT_EQ(rs2.state, 2.0f); // updated, sent
}
#endif
// --- Ping key tests ---
TEST(PacketTransportTest, PingKeyStoredWhenEncrypted) {
@@ -319,73 +127,6 @@ TEST(PacketTransportTest, PingKeyMaxLimit) {
EXPECT_FALSE(transport.ping_keys_.contains("host4"));
}
#ifdef USE_SENSOR
TEST(PacketTransportTest, PingKeyIncludedInTransmittedPacket) {
std::vector<uint8_t> key(32, 0xBB);
// Responder: encrypted, owns a sensor
TestablePacketTransport responder;
responder.init_for_test("responder");
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.add_sensor("temp", &local_sensor);
// Requester sends a MAGIC_PING that the responder processes
auto ping = build_ping_packet("requester", 0xDEADBEEF);
responder.process_({ping.data(), ping.size()});
ASSERT_EQ(responder.ping_keys_.size(), 1u);
// Responder sends sensor data — ping key should be embedded
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
// Requester: encrypted provider, ping-pong enabled, expects key 0xDEADBEEF
TestablePacketTransport requester;
requester.init_for_test("requester");
requester.set_ping_pong_enable(true);
requester.ping_key_ = 0xDEADBEEF;
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
requester.add_remote_sensor("responder", "temp", &remote_sensor);
requester.set_provider_encryption("responder", key);
// The requester decrypts the packet and finds its ping key echoed back,
// which gates the sensor data — if the key is missing, data is blocked.
auto &packet = responder.sent_packets[0];
requester.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 77.7f);
}
TEST(PacketTransportTest, MissingPingKeyBlocksSensorData) {
std::vector<uint8_t> key(32, 0xBB);
// Responder sends data WITHOUT receiving any MAGIC_PING first — no ping keys
TestablePacketTransport responder;
responder.init_for_test("responder");
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.add_sensor("temp", &local_sensor);
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
// Requester with ping-pong enabled expects a key that isn't in the packet
TestablePacketTransport requester;
requester.init_for_test("requester");
requester.set_ping_pong_enable(true);
requester.ping_key_ = 0xDEADBEEF;
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
requester.add_remote_sensor("responder", "temp", &remote_sensor);
requester.set_provider_encryption("responder", key);
auto &packet = responder.sent_packets[0];
requester.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, -999.0f); // blocked — ping key not found
}
#endif
// --- Process error handling ---
TEST(PacketTransportTest, ProcessShortBuffer) {
@@ -0,0 +1,170 @@
#include "../common.h"
namespace esphome::packet_transport::testing {
TEST(PacketTransportSensorTest, AddSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.add_sensor("temp", &s);
ASSERT_EQ(transport.sensors_.size(), 1u);
EXPECT_STREQ(transport.sensors_[0].id, "temp");
EXPECT_EQ(transport.sensors_[0].sensor, &s);
EXPECT_TRUE(transport.sensors_[0].updated);
}
TEST(PacketTransportSensorTest, AddRemoteSensor) {
TestablePacketTransport transport;
sensor::Sensor s;
transport.add_remote_sensor("host1", "remote_temp", &s);
EXPECT_TRUE(transport.providers_.contains("host1"));
EXPECT_EQ(transport.remote_sensors_["host1"]["remote_temp"], &s);
}
TEST(PacketTransportSensorTest, UnencryptedSensorRoundTrip) {
// Encoder
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor local_sensor;
local_sensor.state = 42.5f;
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
// Decoder
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f; // sentinel
decoder.add_remote_sensor("sender", "temp", &remote_sensor);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 42.5f);
}
TEST(PacketTransportSensorTest, EncryptedSensorRoundTrip) {
std::vector<uint8_t> key(32);
for (int i = 0; i < 32; i++)
key[i] = i;
TestablePacketTransport encoder;
encoder.init_for_test("sender");
encoder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 99.9f;
encoder.add_sensor("temp", &local_sensor);
encoder.send_data_(true);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
decoder.add_remote_sensor("sender", "temp", &remote_sensor);
decoder.set_provider_encryption("sender", key);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 99.9f);
}
TEST(PacketTransportSensorTest, SendDataOnlyUpdated) {
TestablePacketTransport encoder;
encoder.init_for_test("sender");
sensor::Sensor s1, s2;
s1.state = 1.0f;
s2.state = 2.0f;
encoder.add_sensor("s1", &s1);
encoder.add_sensor("s2", &s2);
// Mark s1 as not updated, only s2 as updated
encoder.sensors_[0].updated = false;
encoder.sensors_[1].updated = true;
encoder.send_data_(false);
ASSERT_EQ(encoder.sent_packets.size(), 1u);
TestablePacketTransport decoder;
decoder.init_for_test("receiver");
sensor::Sensor rs1, rs2;
rs1.state = -999.0f;
rs2.state = -999.0f;
decoder.add_remote_sensor("sender", "s1", &rs1);
decoder.add_remote_sensor("sender", "s2", &rs2);
auto &packet = encoder.sent_packets[0];
decoder.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(rs1.state, -999.0f); // not updated, not sent
EXPECT_FLOAT_EQ(rs2.state, 2.0f); // updated, sent
}
TEST(PacketTransportSensorTest, PingKeyIncludedInTransmittedPacket) {
std::vector<uint8_t> key(32, 0xBB);
// Responder: encrypted, owns a sensor
TestablePacketTransport responder;
responder.init_for_test("responder");
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.add_sensor("temp", &local_sensor);
// Requester sends a MAGIC_PING that the responder processes
auto ping = build_ping_packet("requester", 0xDEADBEEF);
responder.process_({ping.data(), ping.size()});
ASSERT_EQ(responder.ping_keys_.size(), 1u);
// Responder sends sensor data — ping key should be embedded
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
// Requester: encrypted provider, ping-pong enabled, expects key 0xDEADBEEF
TestablePacketTransport requester;
requester.init_for_test("requester");
requester.set_ping_pong_enable(true);
requester.ping_key_ = 0xDEADBEEF;
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
requester.add_remote_sensor("responder", "temp", &remote_sensor);
requester.set_provider_encryption("responder", key);
// The requester decrypts the packet and finds its ping key echoed back,
// which gates the sensor data — if the key is missing, data is blocked.
auto &packet = responder.sent_packets[0];
requester.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, 77.7f);
}
TEST(PacketTransportSensorTest, MissingPingKeyBlocksSensorData) {
std::vector<uint8_t> key(32, 0xBB);
// Responder sends data WITHOUT receiving any MAGIC_PING first — no ping keys
TestablePacketTransport responder;
responder.init_for_test("responder");
responder.set_encryption_key(key);
sensor::Sensor local_sensor;
local_sensor.state = 77.7f;
responder.add_sensor("temp", &local_sensor);
responder.send_data_(true);
ASSERT_EQ(responder.sent_packets.size(), 1u);
// Requester with ping-pong enabled expects a key that isn't in the packet
TestablePacketTransport requester;
requester.init_for_test("requester");
requester.set_ping_pong_enable(true);
requester.ping_key_ = 0xDEADBEEF;
sensor::Sensor remote_sensor;
remote_sensor.state = -999.0f;
requester.add_remote_sensor("responder", "temp", &remote_sensor);
requester.set_provider_encryption("responder", key);
auto &packet = responder.sent_packets[0];
requester.process_({packet.data(), packet.size()});
EXPECT_FLOAT_EQ(remote_sensor.state, -999.0f); // blocked — ping key not found
}
} // namespace esphome::packet_transport::testing
@@ -102,6 +102,18 @@ uart_mock:
0xF8, 0xF7, 0xF6, 0xF5,
]
# Common filter definitions
.sensor_filters: &sensor_filters
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
.binary_filters: &binary_filters
filters:
- settle: 50ms
ld2412:
id: ld2412_dev
uart_id: mock_uart
@@ -111,107 +123,56 @@ sensor:
ld2412_id: ld2412_dev
moving_distance:
name: "Moving Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
still_distance:
name: "Still Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
moving_energy:
name: "Moving Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
still_energy:
name: "Still Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
detection_distance:
name: "Detection Distance"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
light:
name: "Light"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
gate_0:
move_energy:
name: "Gate 0 Move Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
still_energy:
name: "Gate 0 Still Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
gate_1:
move_energy:
name: "Gate 1 Move Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
still_energy:
name: "Gate 1 Still Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
gate_2:
move_energy:
name: "Gate 2 Move Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
still_energy:
name: "Gate 2 Still Energy"
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
<<: *sensor_filters
binary_sensor:
- platform: ld2412
ld2412_id: ld2412_dev
has_target:
name: "Has Target"
filters:
- settle: 50ms
<<: *binary_filters
has_moving_target:
name: "Has Moving Target"
filters:
- settle: 50ms
<<: *binary_filters
has_still_target:
name: "Has Still Target"
filters:
- settle: 50ms
<<: *binary_filters
button:
- platform: template
@@ -0,0 +1,167 @@
esphome:
name: uart-mock-ld2412-eng-trunc
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 256000
auto_start: false
injections:
# Phase 1 (t=100ms): Valid engineering mode frame (52 bytes, buffer_pos_=52)
# Establishes baseline: gate_0_move=100, light=87
- delay: 100ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x2A, 0x00,
0x01, 0xAA,
0x03,
0x1E, 0x00,
0x64,
0x1E, 0x00,
0x64,
0x00, 0x00,
0x64, 0x41, 0x06, 0x0E, 0x2B, 0x16, 0x03, 0x03, 0x07, 0x05, 0x09, 0x08, 0x07, 0x06,
0x00, 0x00, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64, 0x50, 0x40, 0x30, 0x20, 0x10,
0x57,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 2 (t=200ms): Truncated engineering mode frame (24 bytes, buffer_pos_=24)
# This frame has data_type=0x01 (engineering) but only enough data for the
# basic target fields, not the gate energies or light sensor.
# buffer_pos_=24 passes the old check (>= 12) but fails the new check (< 46).
# Without the fix, indices 17-45 would read stale buffer data from Phase 1.
#
# Layout (24 bytes):
# [0-3] F4 F3 F2 F1 = data frame header
# [4-5] 0E 00 = length 14
# [6] 01 = data type (engineering mode)
# [7] AA = data header marker
# [8] 03 = target states (moving+still)
# [9-10] 1E 00 = moving distance 30
# [11] 50 = moving energy 80
# [12-13] 1E 00 = still distance 30
# [14] 50 = still energy 80
# [15-16] FF FF = garbage detection distance bytes
# [17] FF = padding (would be gate data in full frame)
# [18] 55 = data footer marker (at buffer_pos_ - 6)
# [19] 00 = check byte
# [20-23] F8 F7 F6 F5 = data frame footer
- delay: 100ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x0E, 0x00,
0x01, 0xAA,
0x03,
0x1E, 0x00,
0x50,
0x1E, 0x00,
0x50,
0xFF, 0xFF,
0xFF,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Phase 3 (t=300ms): Valid recovery frame with different values
# gate_0_move=50, light=42 — proves component recovered
- delay: 100ms
inject_rx:
[
0xF4, 0xF3, 0xF2, 0xF1,
0x2A, 0x00,
0x01, 0xAA,
0x03,
0x1E, 0x00,
0x64,
0x1E, 0x00,
0x64,
0x00, 0x00,
0x32, 0x20, 0x06, 0x0E, 0x2B, 0x16, 0x03, 0x03, 0x07, 0x05, 0x09, 0x08, 0x07, 0x06,
0x00, 0x00, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x28, 0x20, 0x18, 0x10, 0x08,
0x2A,
0x55, 0x00,
0xF8, 0xF7, 0xF6, 0xF5,
]
# Common filter definitions
.sensor_filters: &sensor_filters
filters:
- timeout:
timeout: 50ms
value: last
- throttle_with_priority: 50ms
.binary_filters: &binary_filters
filters:
- settle: 50ms
ld2412:
id: ld2412_dev
uart_id: mock_uart
sensor:
- platform: ld2412
ld2412_id: ld2412_dev
moving_distance:
name: "Moving Distance"
<<: *sensor_filters
still_distance:
name: "Still Distance"
<<: *sensor_filters
moving_energy:
name: "Moving Energy"
<<: *sensor_filters
still_energy:
name: "Still Energy"
<<: *sensor_filters
detection_distance:
name: "Detection Distance"
<<: *sensor_filters
light:
name: "Light"
<<: *sensor_filters
gate_0:
move_energy:
name: "Gate 0 Move Energy"
<<: *sensor_filters
still_energy:
name: "Gate 0 Still Energy"
<<: *sensor_filters
binary_sensor:
- platform: ld2412
ld2412_id: ld2412_dev
has_target:
name: "Has Target"
<<: *binary_filters
has_moving_target:
name: "Has Moving Target"
<<: *binary_filters
has_still_target:
name: "Has Still Target"
<<: *binary_filters
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: 'id(mock_uart).start_scenario();'
+125
View File
@@ -14,6 +14,12 @@ test_uart_mock_ld2412_engineering (engineering mode):
2. Multi-byte still distance (291cm) using high byte > 0
3. Gate energy sensor values
4. Detection distance computed from target state
test_uart_mock_ld2412_engineering_truncated (truncated engineering mode):
1. Valid engineering frame establishes baseline sensor values
2. Truncated engineering frame (24 bytes) is rejected gate/light sensors
must not receive garbage from stale buffer data or frame footer bytes
3. Recovery frame with different values proves the component survived
"""
from __future__ import annotations
@@ -273,3 +279,122 @@ async def test_uart_mock_ld2412_engineering(
)
assert pytest.approx(291.0) in collector.sensor_states["detection_distance"]
@pytest.mark.asyncio
async def test_uart_mock_ld2412_engineering_truncated(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that truncated engineering mode frames don't corrupt sensor values.
Without the fix, a 24-byte engineering mode frame passes the old buffer_pos_ >= 12
check but reads indices 17-45 from stale buffer data, publishing garbage values
(e.g. frame footer bytes 0xF8=248 as gate energy).
"""
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
loop = asyncio.get_running_loop()
# Track the truncated frame warning
truncated_warning_seen = loop.create_future()
def line_callback(line: str) -> None:
if (
"Engineering mode packet too short" in line
and not truncated_warning_seen.done()
):
truncated_warning_seen.set_result(True)
collector = SensorStateCollector(
sensor_names=[
"moving_distance",
"still_distance",
"moving_energy",
"still_energy",
"detection_distance",
"light",
"gate_0_move_energy",
"gate_0_still_energy",
],
binary_sensor_names=[
"has_target",
"has_moving_target",
"has_still_target",
],
)
# Signal when we see Phase 3 recovery values (gate_0_move=50)
recovery_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"]
)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
# Wait for Phase 1 — valid engineering frame establishes baseline
try:
await collector.wait_for_all(timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Phase 1 frame. Received:\n"
f" sensor_states: {collector.sensor_states}\n"
f" binary_states: {collector.binary_states}"
)
# Phase 1 baseline: gate_0_move=100, light=87
assert collector.sensor_states["gate_0_move_energy"][0] == pytest.approx(100.0)
assert collector.sensor_states["light"][0] == pytest.approx(87.0)
# Wait for Phase 3 recovery frame (gate_0_move=50)
try:
await asyncio.wait_for(recovery_received, timeout=3.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for recovery frame. Received:\n"
f" gate_0_move_energy: {collector.sensor_states['gate_0_move_energy']}\n"
f" light: {collector.sensor_states['light']}"
)
# Verify the truncated frame warning was logged
assert truncated_warning_seen.done(), (
"Expected 'Engineering mode packet too short' warning in logs"
)
# Phase 3 recovery: gate_0_move=50, light=42
assert pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"]
assert pytest.approx(42.0) in collector.sensor_states["light"]
# The critical assertion: gate_0_move_energy must never have received
# garbage values from the truncated frame. Without the fix,
# buffer_data_[17] = 0xFF = 255 would be published as gate_0_move.
for value in collector.sensor_states["gate_0_move_energy"]:
assert value == pytest.approx(100.0) or value == pytest.approx(50.0), (
f"gate_0_move_energy got unexpected value {value}"
f"truncated frame likely leaked stale buffer data. "
f"All values: {collector.sensor_states['gate_0_move_energy']}"
)
+91
View File
@@ -1027,6 +1027,73 @@ def test_get_all_dependencies_empty_set() -> None:
assert result == set()
def test_get_all_dependencies_platform_component() -> None:
"""Platform components (domain.component) are looked up via get_platform,
not get_component."""
platform_comp = Mock()
platform_comp.dependencies = []
platform_comp.auto_load = []
with (
patch("esphome.loader.get_component") as mock_get_component,
patch("helpers.get_platform") as mock_get_platform,
):
mock_get_platform.return_value = platform_comp
mock_get_component.return_value = None
result = helpers.get_all_dependencies({"sensor.bthome"})
mock_get_platform.assert_called_once_with("sensor", "bthome")
mock_get_component.assert_not_called()
assert result == {"sensor.bthome"}
def test_get_all_dependencies_platform_component_with_dependencies() -> None:
"""Dependencies of a platform component are resolved transitively."""
platform_comp = Mock()
platform_comp.dependencies = ["sensor"]
platform_comp.auto_load = []
sensor_comp = Mock()
sensor_comp.dependencies = []
sensor_comp.auto_load = []
with (
patch("esphome.loader.get_component") as mock_get_component,
patch("helpers.get_platform") as mock_get_platform,
):
mock_get_platform.return_value = platform_comp
mock_get_component.side_effect = lambda name: (
sensor_comp if name == "sensor" else None
)
result = helpers.get_all_dependencies({"sensor.bthome"})
assert result == {"sensor.bthome", "sensor"}
def test_get_all_dependencies_cpp_testing_flag() -> None:
"""cpp_testing=True propagates to CORE.cpp_testing during resolution."""
from esphome.core import CORE
with (
patch("esphome.loader.get_component") as mock_get_component,
patch("esphome.loader.get_platform"),
):
observed: list[bool] = []
def capturing_get_component(name: str):
observed.append(CORE.cpp_testing)
mock_get_component.side_effect = capturing_get_component
helpers.get_all_dependencies({"some_comp"}, cpp_testing=True)
assert observed and all(observed), (
"CORE.cpp_testing should be True during resolution"
)
def test_get_components_from_integration_fixtures() -> None:
"""Test extraction of components from fixture YAML files."""
yaml_content = {
@@ -1057,6 +1124,30 @@ def test_get_components_from_integration_fixtures() -> None:
assert components == expected_components
def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None:
"""Test that YAML anchor keys (starting with '.') are excluded."""
yaml_content = {
"sensor": [{"platform": "template", "name": "test"}],
"esphome": {"name": "test"},
".sensor_filters": {"filters": [{"timeout": "50ms"}]},
".binary_filters": {"filters": [{"settle": "50ms"}]},
}
mock_yaml_file = Mock()
with (
patch("pathlib.Path.glob") as mock_glob,
patch("esphome.yaml_util.load_yaml", return_value=yaml_content),
):
mock_glob.return_value = [mock_yaml_file]
components = helpers.get_components_from_integration_fixtures()
assert ".sensor_filters" not in components
assert ".binary_filters" not in components
assert components == {"sensor", "esphome", "template"}
@pytest.mark.parametrize(
"output,expected",
[
+12
View File
@@ -841,6 +841,18 @@ class TestEsphomeCore:
assert "WiFi" in target.platformio_libraries
def test_testing_ensure_platform_registered__sets_count(self, target):
"""Test testing_ensure_platform_registered sets count to 1 for new platform."""
assert target.platform_counts["sensor"] == 0
target.testing_ensure_platform_registered("sensor")
assert target.platform_counts["sensor"] == 1
def test_testing_ensure_platform_registered__does_not_overwrite(self, target):
"""Test testing_ensure_platform_registered preserves existing count."""
target.platform_counts["sensor"] = 3
target.testing_ensure_platform_registered("sensor")
assert target.platform_counts["sensor"] == 3
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
+69 -5
View File
@@ -76,6 +76,7 @@ from esphome.const import (
PLATFORM_RP2040,
)
from esphome.core import CORE, EsphomeError
from esphome.util import BootselResult
def strip_ansi_codes(text: str) -> str:
@@ -875,7 +876,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel(
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=1),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)),
):
result = choose_upload_log_host(
default=None,
@@ -898,7 +899,7 @@ def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None:
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=0),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)),
pytest.raises(EsphomeError, match="BOOTSEL"),
):
choose_upload_log_host(
@@ -923,7 +924,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota(
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=0),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)),
patch(
"esphome.__main__.choose_prompt",
return_value="192.168.1.100",
@@ -952,7 +953,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports(
"esphome.__main__._find_picotool",
return_value=Path("/usr/bin/picotool"),
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=0),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)),
caplog.at_level(logging.INFO, logger="esphome.__main__"),
):
choose_upload_log_host(
@@ -963,6 +964,69 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports(
assert "BOOTSEL" in caplog.text
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_rp2040_permission_error_no_options(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test permission warning shown when BOOTSEL device found but not accessible."""
setup_core(platform=PLATFORM_RP2040)
with (
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch(
"esphome.__main__.detect_rp2040_bootsel",
return_value=BootselResult(0, permission_error=True),
),
patch("esphome.__main__.sys.platform", "linux"),
pytest.raises(EsphomeError, match="BOOTSEL"),
caplog.at_level(logging.WARNING, logger="esphome.__main__"),
):
choose_upload_log_host(
default=None,
check_default=None,
purpose=Purpose.UPLOADING,
)
assert "USB permissions" in caplog.text
assert "udev" in caplog.text
@pytest.mark.usefixtures("mock_no_serial_ports")
def test_choose_upload_log_host_rp2040_permission_error_with_ota(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test permission warning shown with OTA fallback available."""
setup_core(
platform=PLATFORM_RP2040,
config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]},
address="192.168.1.100",
)
with (
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch(
"esphome.__main__.detect_rp2040_bootsel",
return_value=BootselResult(0, permission_error=True),
),
patch(
"esphome.__main__.choose_prompt",
return_value="192.168.1.100",
),
caplog.at_level(logging.WARNING, logger="esphome.__main__"),
):
choose_upload_log_host(
default=None,
check_default=None,
purpose=Purpose.UPLOADING,
)
assert "USB permissions" in caplog.text
def test_choose_upload_log_host_no_bootsel_for_non_rp2040(
mock_no_serial_ports: Mock,
) -> None:
@@ -1000,7 +1064,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel(
patch(
"esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool")
),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=1),
patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)),
):
choose_upload_log_host(
default=None,
+44 -10
View File
@@ -463,8 +463,9 @@ def test_detect_rp2040_bootsel_found() -> None:
mock_result = MagicMock()
mock_result.stdout = b"Device Information\n type: RP2040\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 1
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 1
assert result.permission_error is False
def test_detect_rp2040_bootsel_multiple() -> None:
@@ -472,8 +473,9 @@ def test_detect_rp2040_bootsel_multiple() -> None:
mock_result = MagicMock()
mock_result.stdout = b"type: RP2040\ntype: RP2350\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 2
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 2
assert result.permission_error is False
def test_detect_rp2040_bootsel_none() -> None:
@@ -482,16 +484,47 @@ def test_detect_rp2040_bootsel_none() -> None:
mock_result.stdout = (
b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n"
)
mock_result.stderr = b""
with patch("esphome.util.subprocess.run", return_value=mock_result):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is False
def test_detect_rp2040_bootsel_permission_error() -> None:
"""Test BOOTSEL detection with device found but not accessible."""
mock_result = MagicMock()
mock_result.stdout = (
b"No accessible RP-series devices in BOOTSEL mode were found.\n"
)
mock_result.stderr = (
b"RP2040 device at bus 5, address 24 appears to be in BOOTSEL mode, "
b"but picotool was unable to connect. "
b"Maybe try 'sudo' or check your permissions.\n"
)
with patch("esphome.util.subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is True
def test_detect_rp2040_bootsel_libusb_access_error() -> None:
"""Test BOOTSEL detection with LIBUSB_ERROR_ACCESS."""
mock_result = MagicMock()
mock_result.stdout = b""
mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n"
with patch("esphome.util.subprocess.run", return_value=mock_result):
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is True
def test_detect_rp2040_bootsel_oserror() -> None:
"""Test BOOTSEL detection handles OSError."""
with patch("esphome.util.subprocess.run", side_effect=OSError("not found")):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is False
def test_detect_rp2040_bootsel_timeout() -> None:
@@ -500,8 +533,9 @@ def test_detect_rp2040_bootsel_timeout() -> None:
"esphome.util.subprocess.run",
side_effect=subprocess.TimeoutExpired("picotool", 10),
):
count = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert count == 0
result = util.detect_rp2040_bootsel("/usr/bin/picotool")
assert result.device_count == 0
assert result.permission_error is False
def _make_redirect(