Merge branch 'dev' into ble_set_security_params

This commit is contained in:
J. Nick Koston
2026-02-26 23:54:21 -07:00
committed by GitHub
585 changed files with 19964 additions and 12099 deletions
@@ -15,8 +15,30 @@ esp_ldo:
display:
- platform: mipi_dsi
id: p4_nano
model: WAVESHARE-P4-NANO-10.1
rotation: 90
- platform: mipi_dsi
id: p4_86
model: "WAVESHARE-P4-86-PANEL"
rotation: 180
- platform: mipi_dsi
model: custom
id: custom_id
dimensions:
width: 400
height: 1280
hsync_back_porch: 40
hsync_pulse_width: 30
hsync_front_porch: 40
vsync_back_porch: 20
vsync_pulse_width: 10
vsync_front_porch: 20
pclk_frequency: 48Mhz
lane_bit_rate: 1.2Gbps
rotation: 180
transform: disabled
init_sequence:
i2c:
sda: GPIO7
scl: GPIO8
@@ -119,9 +119,12 @@ def test_code_generation(
main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml"))
assert (
"mipi_dsi_mipi_dsi_id = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);"
"p4_nano = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);"
in main_cpp
)
assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
assert "mipi_dsi_mipi_dsi_id->set_lane_bit_rate(1500);" in main_cpp
assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp
assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml
+96
View File
@@ -35,3 +35,99 @@ button:
data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]
- cc1101.send_packet: !lambda |-
return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
- cc1101.set_frequency: !lambda |-
return 433.91e6;
- cc1101.set_frequency:
value: "433.91MHz"
- cc1101.set_frequency:
value: 433911000
- cc1101.set_frequency: 433912000
- cc1101.set_output_power: !lambda |-
return -29.9;
- cc1101.set_output_power:
value: "-28"
- cc1101.set_output_power:
value: 10
- cc1101.set_output_power: 11
- cc1101.set_modulation_type: !lambda |-
return cc1101::Modulation::MODULATION_2_FSK;
- cc1101.set_modulation_type:
value: "4-FSK"
- cc1101.set_modulation_type: "GFSK"
- cc1101.set_symbol_rate: !lambda |-
return 6000.0;
- cc1101.set_symbol_rate:
value: "7000.0"
- cc1101.set_symbol_rate:
value: 8000.0
- cc1101.set_symbol_rate: 9000
- cc1101.set_rx_attenuation: !lambda |-
return cc1101::RxAttenuation::RX_ATTENUATION_0DB;
- cc1101.set_rx_attenuation:
value: "6dB"
- cc1101.set_rx_attenuation: "12dB"
- cc1101.set_dc_blocking_filter: !lambda |-
return false;
- cc1101.set_dc_blocking_filter:
value: true
- cc1101.set_dc_blocking_filter: false
- cc1101.set_manchester: !lambda |-
return false;
- cc1101.set_manchester:
value: true
- cc1101.set_manchester: false
- cc1101.set_filter_bandwidth: !lambda |-
return 58e3;
- cc1101.set_filter_bandwidth:
value: "59kHz"
- cc1101.set_filter_bandwidth:
value: 60000
- cc1101.set_filter_bandwidth: "61kHz"
- cc1101.set_fsk_deviation: !lambda |-
return 1.5e3;
- cc1101.set_fsk_deviation:
value: "1.6kHz"
- cc1101.set_fsk_deviation:
value: 1700
- cc1101.set_fsk_deviation: "1.8kHz"
- cc1101.set_msk_deviation: !lambda |-
return 1;
- cc1101.set_msk_deviation:
value: "2"
- cc1101.set_msk_deviation:
value: 3
- cc1101.set_msk_deviation: "4"
- cc1101.set_channel: !lambda |-
return 0;
- cc1101.set_channel:
value: "1"
- cc1101.set_channel:
value: 3
- cc1101.set_channel: 3
- cc1101.set_channel_spacing: !lambda |-
return 25e3;
- cc1101.set_channel_spacing:
value: "26kHz"
- cc1101.set_channel_spacing:
value: 27000
- cc1101.set_channel_spacing: "28kHz"
- cc1101.set_if_frequency: !lambda |-
return 25e3;
- cc1101.set_if_frequency:
value: "26kHz"
- cc1101.set_if_frequency:
value: 27000
- cc1101.set_if_frequency: "28kHz"
+2 -2
View File
@@ -27,9 +27,9 @@ sensor:
name: Linearly combined temperatures
sources:
- source: template_temperature1
coeffecient: !lambda "return 0.4 + std::abs(x - 25) * 0.023;"
coefficient: !lambda "return 0.4 + std::abs(x - 25) * 0.023;"
- source: template_temperature2
coeffecient: 1.5
coefficient: 1.5
- platform: combination
type: max
name: Max of combined temperatures
+9
View File
@@ -1,4 +1,13 @@
esphome:
on_boot:
then:
- lambda: |-
// Test deprecated std::string overload still compiles
std::string key = "00112233445566778899aabbccddeeff";
id(dsmr_instance).set_decryption_key(key);
dsmr:
id: dsmr_instance
decryption_key: 00112233445566778899aabbccddeeff
max_telegram_length: 1000
request_pin: ${request_pin}
@@ -57,6 +57,23 @@ display:
allow_other_uses: true
number: GPIO4
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-7.5in-H
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
- platform: epaper_spi
model: seeed-reterminal-e1002
- platform: epaper_spi
@@ -64,3 +81,66 @@ display:
# Override pins to avoid conflict with other display configs
busy_pin: 43
dc_pin: 42
# WeAct 2.13" 3-color e-paper (122x250, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: weact-2.13in-3c
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 2.9" 3-color e-paper (128x296, SSD1683)
- platform: epaper_spi
spi_id: spi_bus
model: weact-2.9in-3c
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 4.2" 3-color e-paper (400x300, SSD1683)
- platform: epaper_spi
spi_id: spi_bus
model: weact-4.2in-3c
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0));
@@ -1,5 +1,6 @@
esp32:
variant: esp32p4
engineering_sample: true
flash_size: 32MB
cpu_frequency: 400MHz
framework:
@@ -0,0 +1,5 @@
<<: !include common.yaml
esp32_ble:
io_capability: keyboard_only
disable_bt_logs: false
@@ -33,6 +33,10 @@ esp32_ble_server:
- uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc42d
advertise: false
characteristics:
- id: test_lambda_characteristic
uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc12c
read: true
value: !lambda return { 1, 2 };
- id: test_change_characteristic
uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc11c
read: true
@@ -4,7 +4,6 @@ esp32_touch:
measurement_duration: 8ms
low_voltage_reference: 0.5V
high_voltage_reference: 2.7V
voltage_attenuation: 1.5V
binary_sensor:
- platform: esp32_touch
@@ -0,0 +1,5 @@
substitutions:
pin: GPIO5
<<: !include common-variants.yaml
<<: !include common-get-value.yaml
@@ -3,10 +3,13 @@ esp_ldo:
channel: 3
voltage: 2.5V
adjustable: true
- id: ldo_4
- id: ldo_4_passthrough
channel: 4
voltage: 2.0V
setup_priority: 900
voltage: passthrough
- id: ldo_1_internal
channel: 1
voltage: 1.8V
allow_internal_channel: true
esphome:
on_boot:
+19
View File
@@ -0,0 +1,19 @@
esphome:
on_boot:
then:
- hdc302x.heater_on:
id: hdc302x_sensor
power: QUARTER
duration: 5s
- hdc302x.heater_off:
id: hdc302x_sensor
sensor:
- platform: hdc302x
id: hdc302x_sensor
i2c_id: i2c_bus
temperature:
name: Temperature
humidity:
name: Humidity
update_interval: 15s
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
@@ -90,6 +90,19 @@ text_sensor:
id: ha_hello_world_text2
attribute: some_attribute
event:
- platform: template
name: Test Event
id: test_event
event_types:
- test_event_type
on_event:
- homeassistant.event:
event: esphome.test_event
data:
event_name: !lambda |-
return event_type;
time:
- platform: homeassistant
on_time:
+7 -6
View File
@@ -4,15 +4,16 @@ interval:
- interval: 60s
then:
- lambda: |-
// Test build_json
std::string json_str = esphome::json::build_json([](JsonObject root) {
// Test build_json - returns SerializationBuffer, use auto to avoid heap allocation
auto json_buf = esphome::json::build_json([](JsonObject root) {
root["sensor"] = "temperature";
root["value"] = 23.5;
root["unit"] = "°C";
});
ESP_LOGD("test", "Built JSON: %s", json_str.c_str());
ESP_LOGD("test", "Built JSON: %s", json_buf.c_str());
// Test parse_json
// Test parse_json - implicit conversion to std::string for backward compatibility
std::string json_str = json_buf;
bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) {
if (root["sensor"].is<const char*>() && root["value"].is<float>()) {
const char* sensor = root["sensor"];
@@ -26,10 +27,10 @@ interval:
});
ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed");
// Test JsonBuilder class
// Test JsonBuilder class - returns SerializationBuffer
esphome::json::JsonBuilder builder;
JsonObject obj = builder.root();
obj["test"] = "direct_builder";
obj["count"] = 42;
std::string result = builder.serialize();
auto result = builder.serialize();
ESP_LOGD("test", "JsonBuilder result: %s", result.c_str());
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "esphome/components/ld2450/ld2450.h"
#include "esphome/components/uart/uart_component.h"
namespace esphome::ld2450::testing {
// Mock UART component to satisfy UARTDevice parent requirement.
class MockUARTComponent : public uart::UARTComponent {
public:
void write_array(const uint8_t *data, size_t len) override {}
MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override));
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(void, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
};
// Expose protected members for testing.
class TestableLD2450 : public LD2450Component {
public:
using LD2450Component::buffer_data_;
using LD2450Component::buffer_pos_;
using LD2450Component::readline_;
void feed(const std::vector<uint8_t> &data) {
for (uint8_t byte : data) {
this->readline_(byte);
}
}
};
// LD2450 periodic data frame: header (4) + 3 targets * 8 bytes + footer (2) = 30 bytes
// All-zero targets means no presence detected.
inline std::vector<uint8_t> make_periodic_frame(uint8_t fill = 0x00) {
std::vector<uint8_t> frame = {0xAA, 0xFF, 0x03, 0x00}; // DATA_FRAME_HEADER
for (int i = 0; i < 24; i++) {
frame.push_back(fill); // 3 targets * 8 bytes
}
frame.push_back(0x55); // DATA_FRAME_FOOTER
frame.push_back(0xCC);
return frame;
}
// LD2450 command ACK frame for CMD_ENABLE_CONF (0xFF), successful.
// header (4) + length (2) + command (2) + result (2) + footer (4) = 14 bytes
inline std::vector<uint8_t> make_ack_frame() {
return {
0xFD, 0xFC, 0xFB, 0xFA, // CMD_FRAME_HEADER
0x04, 0x00, // length = 4
0xFF, 0x01, // command = enable_conf, status = success
0x00, 0x00, // result = ok
0x04, 0x03, 0x02, 0x01 // CMD_FRAME_FOOTER
};
}
} // namespace esphome::ld2450::testing
+145
View File
@@ -0,0 +1,145 @@
#include "common.h"
namespace esphome::ld2450::testing {
class LD2450ReadlineTest : public ::testing::Test {
protected:
void SetUp() override {
this->ld2450_.set_uart_parent(&this->mock_uart_);
// Ensure clean state
ASSERT_EQ(this->ld2450_.buffer_pos_, 0);
}
MockUARTComponent mock_uart_;
TestableLD2450 ld2450_;
};
// --- Good data tests ---
TEST_F(LD2450ReadlineTest, ValidPeriodicFrame) {
auto frame = make_periodic_frame();
this->ld2450_.feed(frame);
// After a complete valid frame, buffer should be reset
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
TEST_F(LD2450ReadlineTest, ValidCommandAckFrame) {
auto frame = make_ack_frame();
this->ld2450_.feed(frame);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
TEST_F(LD2450ReadlineTest, BackToBackPeriodicFrames) {
auto frame = make_periodic_frame();
for (int i = 0; i < 5; i++) {
this->ld2450_.feed(frame);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0) << "Frame " << i << " not processed";
}
}
TEST_F(LD2450ReadlineTest, BackToBackMixedFrames) {
auto periodic = make_periodic_frame();
auto ack = make_ack_frame();
this->ld2450_.feed(periodic);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
this->ld2450_.feed(ack);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
this->ld2450_.feed(periodic);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
// --- Garbage then valid frame tests ---
TEST_F(LD2450ReadlineTest, GarbageThenValidFrame) {
// Garbage bytes accumulate in the buffer but don't match any footer.
// A valid frame follows; its footer resets the buffer and resyncs.
std::vector<uint8_t> garbage = {0x01, 0x02, 0x03, 0x42, 0x99};
this->ld2450_.feed(garbage);
EXPECT_GT(this->ld2450_.buffer_pos_, 0); // Garbage accumulated
auto frame = make_periodic_frame();
this->ld2450_.feed(frame);
// Footer from the valid frame resyncs the parser
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
// --- Footer-based resynchronization tests ---
TEST_F(LD2450ReadlineTest, FooterInGarbageResyncs) {
// Garbage containing a periodic frame footer (0x55 0xCC) triggers
// a buffer reset, allowing the next frame to be parsed cleanly.
std::vector<uint8_t> garbage_with_footer = {0x01, 0x02, 0x03, 0x04, 0x55, 0xCC};
this->ld2450_.feed(garbage_with_footer);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0); // Footer reset the buffer
auto frame = make_periodic_frame();
this->ld2450_.feed(frame);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
TEST_F(LD2450ReadlineTest, CmdFooterInGarbageResyncs) {
// Garbage containing a command frame footer (04 03 02 01) also resyncs.
std::vector<uint8_t> garbage_with_footer = {0x10, 0x20, 0x30, 0x40, 0x04, 0x03, 0x02, 0x01};
this->ld2450_.feed(garbage_with_footer);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
auto frame = make_periodic_frame();
this->ld2450_.feed(frame);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
// --- Overflow recovery tests ---
TEST_F(LD2450ReadlineTest, OverflowResetsBuffer) {
// Fill the buffer to capacity with filler that won't match any footer.
// MAX_LINE_LENGTH is 45, usable is 44. The 45th byte triggers overflow.
std::vector<uint8_t> overflow_data(MAX_LINE_LENGTH, 0x11);
this->ld2450_.feed(overflow_data);
// After overflow, buffer_pos_ resets to 0 (via the < 4 early return path)
EXPECT_LT(this->ld2450_.buffer_pos_, 4);
}
TEST_F(LD2450ReadlineTest, OverflowThenValidFrame) {
// Overflow, then a valid frame should be processed.
std::vector<uint8_t> overflow_data(MAX_LINE_LENGTH, 0x11);
this->ld2450_.feed(overflow_data);
auto frame = make_periodic_frame();
this->ld2450_.feed(frame);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
TEST_F(LD2450ReadlineTest, BufferLargeEnoughForDesyncedFooter) {
// The key fix: the buffer (45) is large enough that a desynced periodic frame's
// footer (at most 30 bytes into the stream) will land inside the buffer before overflow.
// Simulate starting 10 bytes into a periodic frame, then a full frame follows.
std::vector<uint8_t> mid_frame = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39};
// Then a complete periodic frame whose footer will land at position 40 (10 + 30),
// well within the buffer size of 45.
auto frame = make_periodic_frame();
mid_frame.insert(mid_frame.end(), frame.begin(), frame.end());
this->ld2450_.feed(mid_frame);
// The footer from the frame should have triggered a reset
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
TEST_F(LD2450ReadlineTest, SimulatedRestartThenFrames) {
// Simulate LD2450 restart: burst of garbage followed by valid periodic frames.
// The garbage + first frame should fit in the buffer so the footer resyncs.
std::vector<uint8_t> restart_noise = {
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, // 8 bytes of mid-frame data
};
auto frame = make_periodic_frame();
// 8 garbage + 30 frame = 38 bytes, well within buffer of 45
restart_noise.insert(restart_noise.end(), frame.begin(), frame.end());
this->ld2450_.feed(restart_noise);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
// Subsequent frames should work normally
this->ld2450_.feed(frame);
EXPECT_EQ(this->ld2450_.buffer_pos_, 0);
}
} // namespace esphome::ld2450::testing
+26
View File
@@ -71,6 +71,32 @@ esphome:
- light.control:
id: test_monochromatic_light
state: on
# Test static effect name resolution at codegen time
- light.turn_on:
id: test_monochromatic_light
effect: Strobe
- light.turn_on:
id: test_monochromatic_light
effect: none
# Test resolving a different effect on the same light
- light.control:
id: test_monochromatic_light
effect: My Flicker
# Test effect: None (capitalized)
- light.control:
id: test_monochromatic_light
effect: None
# Test effect lambda with no args (on_boot has empty Ts...)
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return "Strobe";'
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
- repeat:
count: 3
then:
- light.turn_on:
id: test_monochromatic_light
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
- light.dim_relative:
id: test_monochromatic_light
relative_brightness: 5%
@@ -5,3 +5,6 @@ esphome:
logger:
level: DEBUG
wait_for_cdc: true
early_message: true
task_log_buffer_size: 0
+7 -7
View File
@@ -13,10 +13,10 @@ esphome:
on_boot:
- priority: 100
then:
- max7129digit.invert_off:
- max7129digit.invert_on:
- max7129digit.turn_on:
- max7129digit.turn_off:
- max7129digit.reverse_on:
- max7129digit.reverse_off:
- max7129digit.intensity: 10
- max7219digit.invert_off:
- max7219digit.invert_on:
- max7219digit.turn_on:
- max7219digit.turn_off:
- max7219digit.reverse_on:
- max7219digit.reverse_off:
- max7219digit.intensity: 10
+25
View File
@@ -23,8 +23,27 @@ media_player:
- media_player.stop:
- media_player.stop:
announcement: true
on_announcement:
- media_player.play:
on_turn_on:
- media_player.play:
on_turn_off:
- media_player.stop:
on_pause:
- media_player.toggle:
- media_player.turn_on:
- media_player.turn_off:
- media_player.next:
- media_player.previous:
- media_player.mute:
- media_player.unmute:
- media_player.repeat_off:
- media_player.repeat_one:
- media_player.repeat_all:
- media_player.shuffle:
- media_player.unshuffle:
- media_player.group_join:
- media_player.clear_playlist:
- wait_until:
media_player.is_idle:
- wait_until:
@@ -33,6 +52,12 @@ media_player:
media_player.is_announcing:
- wait_until:
media_player.is_paused:
- wait_until:
media_player.is_on:
- wait_until:
media_player.is_off:
- wait_until:
media_player.is_muted:
- media_player.volume_up:
- media_player.volume_down:
- media_player.volume_set: 50%
+21 -3
View File
@@ -3,9 +3,15 @@ display:
spi_16: true
pixel_mode: 18bit
model: ili9488
dc_pin: ${dc_pin}
cs_pin: ${cs_pin}
reset_pin: ${reset_pin}
dc_pin:
allow_other_uses: true
number: ${dc_pin}
cs_pin:
allow_other_uses: true
number: ${cs_pin}
reset_pin:
allow_other_uses: true
number: ${reset_pin}
data_rate: 20MHz
invert_colors: true
show_test_card: true
@@ -24,3 +30,15 @@ display:
height: 200
enable_pin: ${enable_pin}
bus_mode: single
- platform: mipi_spi
model: WAVESHARE-1.83-V2
dc_pin:
allow_other_uses: true
number: ${dc_pin}
cs_pin:
allow_other_uses: true
number: ${cs_pin}
reset_pin:
allow_other_uses: true
number: ${reset_pin}
+48
View File
@@ -219,6 +219,7 @@ cover:
name: Template Cover
state_topic: some/topic/cover
qos: 2
mqtt_json_state_payload: true
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
@@ -231,6 +232,53 @@ cover:
stop_action:
- logger.log: stop_action
optimistic: true
- platform: template
name: Template Cover with Position and Tilt
state_topic: some/topic/cover_pt
position_state_topic: some/topic/cover_pt/position
position_command_topic: some/topic/cover_pt/position/set
tilt_state_topic: some/topic/cover_pt/tilt
tilt_command_topic: some/topic/cover_pt/tilt/set
qos: 2
has_position: true
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
}
return COVER_CLOSED;
position_action:
- logger.log: position_action
tilt_action:
- logger.log: tilt_action
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
optimistic: true
- platform: template
name: Template Cover with Position and Tilt JSON
state_topic: some/topic/cover_pt_json
qos: 2
mqtt_json_state_payload: true
has_position: true
lambda: |-
if (id(some_binary_sensor).state) {
return COVER_OPEN;
}
return COVER_CLOSED;
position_action:
- logger.log: position_action
tilt_action:
- logger.log: tilt_action
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
optimistic: true
datetime:
- platform: template
@@ -0,0 +1,10 @@
sensor:
- platform: pulse_counter
name: Pulse Counter
pin: 4
use_pcnt: false
count_mode:
rising_edge: INCREMENT
falling_edge: DECREMENT
internal_filter: 13us
update_interval: 15s
@@ -0,0 +1,12 @@
remote_receiver:
id: rcvr
pin: GPIO2
dump: all
<<: !include common-actions.yaml
binary_sensor:
- platform: remote_receiver
name: Panasonic Remote Input
panasonic:
address: 0x4004
command: 0x100BCBD
@@ -0,0 +1,7 @@
remote_transmitter:
id: xmitr
pin: GPIO2
carrier_duty_percent: 50%
packages:
buttons: !include common-buttons.yaml
+11
View File
@@ -0,0 +1,11 @@
substitutions:
network_enable_ipv6: "false"
socket:
wifi:
ssid: MySSID
password: password1
network:
enable_ipv6: ${network_enable_ipv6}
@@ -0,0 +1,4 @@
substitutions:
network_enable_ipv6: "true"
<<: !include common.yaml
@@ -0,0 +1,4 @@
socket:
network:
enable_ipv6: true
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
+3
View File
@@ -0,0 +1,3 @@
socket:
network:
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -1,9 +1,21 @@
from esphome.components import socket
from esphome.const import (
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
)
from esphome.core import CORE
def _setup_platform(platform=PLATFORM_ESP8266) -> None:
"""Set up CORE.data with a platform for testing."""
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
def test_require_wake_loop_threadsafe__first_call() -> None:
"""Test that first call sets up define and consumes socket."""
_setup_platform()
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
@@ -32,6 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None:
def test_require_wake_loop_threadsafe__multiple_calls() -> None:
"""Test that multiple calls only set up once."""
_setup_platform()
# Call three times
CORE.config = {"openthread": True}
socket.require_wake_loop_threadsafe()
@@ -66,12 +79,38 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() -
CORE.config = {"logger": {}}
# Track initial socket consumer state
initial_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {})
initial_udp = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
# Call require_wake_loop_threadsafe
socket.require_wake_loop_threadsafe()
# Verify no socket was consumed
consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS, {})
assert "socket.wake_loop_threadsafe" not in consumers
assert consumers == initial_consumers
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
assert udp_consumers == initial_udp
def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None:
"""Test that ESP32 uses task notifications instead of UDP socket."""
_setup_platform(PLATFORM_ESP32)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify the define was added
assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True
assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines)
# Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications)
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert "socket.wake_loop_threadsafe" not in udp_consumers
def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None:
"""Test that non-ESP32 platforms consume a UDP socket for wake notifications."""
_setup_platform(PLATFORM_ESP8266)
CORE.config = {"wifi": True}
socket.require_wake_loop_threadsafe()
# Verify UDP socket was consumed
udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {})
assert udp_consumers.get("socket.wake_loop_threadsafe") == 1
@@ -1,10 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO23
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common-audio_dac.yaml
@@ -1,5 +1,11 @@
<<: !include common.yaml
wifi:
ap:
psram:
mode: quad
media_player:
- platform: speaker
id: speaker_media_player_id
@@ -10,3 +16,4 @@ media_player:
volume_max: 0.95
volume_min: 0.0
task_stack_in_psram: true
codec_support_enabled: all
@@ -1,9 +0,0 @@
substitutions:
scl_pin: GPIO2
sda_pin: GPIO3
i2s_bclk_pin: GPIO4
i2s_lrclk_pin: GPIO5
i2s_mclk_pin: GPIO6
i2s_dout_pin: GPIO7
<<: !include common-media_player.yaml
@@ -1,10 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1,3 +1,16 @@
text_sensor:
- platform: version
name: "ESPHome Version"
name: "ESPHome Version Full"
- platform: version
name: "ESPHome Version No Timestamp"
hide_timestamp: true
- platform: version
name: "ESPHome Version No Hash"
hide_hash: true
- platform: version
name: "ESPHome Version Shortest"
hide_timestamp: true
hide_hash: true
@@ -0,0 +1,5 @@
wifi:
band_mode: 5GHZ
packages:
- !include common.yaml
+1 -1
View File
@@ -13,7 +13,7 @@ using namespace esphome;
void setup() {
App.pre_setup("livingroom", "LivingRoom", false);
auto *log = new logger::Logger(115200, 512); // NOLINT
auto *log = new logger::Logger(115200); // NOLINT
log->pre_setup();
log->set_uart_selection(logger::UART_SELECTION_UART0);
App.register_component(log);
+1
View File
@@ -197,6 +197,7 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
" platformio_options:\n"
" build_flags:\n"
' - "-DDEBUG" # Enable assert() statements\n'
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
' - "-g" # Add debug symbols',
)
@@ -46,6 +46,7 @@ sensor:
binary_sensor:
- platform: template
id: motion_detected
name: Motion Detected
device_id: motion_sensor
lambda: return true;
@@ -82,3 +83,117 @@ output:
write_action:
- lambda: |-
ESP_LOGD("test", "Light output: %d", state);
cover:
- platform: template
name: Garage Door
device_id: motion_sensor
optimistic: true
fan:
- platform: template
name: Ceiling Fan
device_id: humidity_monitor
speed_count: 3
has_oscillating: false
has_direction: false
lock:
- platform: template
name: Front Door Lock
device_id: motion_sensor
optimistic: true
number:
- platform: template
name: Target Temperature
device_id: temperature_monitor
optimistic: true
min_value: 0
max_value: 100
step: 1
select:
- platform: template
name: Mode Select
device_id: humidity_monitor
optimistic: true
options:
- "Auto"
- "Manual"
text:
- platform: template
name: Device Label
device_id: temperature_monitor
optimistic: true
mode: text
valve:
- platform: template
name: Water Valve
device_id: humidity_monitor
optimistic: true
globals:
- id: global_away
type: bool
initial_value: "false"
- id: global_is_on
type: bool
initial_value: "true"
water_heater:
- platform: template
name: Test Boiler
device_id: temperature_monitor
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
away: !lambda "return id(global_away);"
is_on: !lambda "return id(global_is_on);"
supported_modes:
- "off"
- electric
visual:
min_temperature: 30.0
max_temperature: 85.0
target_temperature_step: 0.5
set_action:
- lambda: |-
ESP_LOGD("test", "Water heater set");
alarm_control_panel:
- platform: template
name: House Alarm
device_id: motion_sensor
codes:
- "1234"
restore_mode: ALWAYS_DISARMED
binary_sensors:
- input: motion_detected
datetime:
- platform: template
name: Schedule Date
device_id: temperature_monitor
type: date
optimistic: true
- platform: template
name: Schedule Time
device_id: humidity_monitor
type: time
optimistic: true
- platform: template
name: Schedule DateTime
device_id: motion_sensor
type: datetime
optimistic: true
event:
- platform: template
name: Doorbell
device_id: motion_sensor
event_types:
- "press"
- "double_press"
@@ -28,6 +28,11 @@ sensor:
id: source_sensor_4
accuracy_decimals: 1
- platform: template
name: "Source Sensor 5"
id: source_sensor_5
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -69,6 +74,13 @@ sensor:
filters:
- delta: 0
- platform: copy
source_id: source_sensor_5
name: "Filter Percentage"
id: filter_percentage
filters:
- delta: 50%
script:
- id: test_filter_min
then:
@@ -154,6 +166,28 @@ script:
id: source_sensor_4
state: 2.0
- id: test_filter_percentage
then:
- sensor.template.publish:
id: source_sensor_5
state: 100.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 120.0 # Filtered out (delta=20, need >50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 160.0 # Passes (delta=60 > 50% of 100=50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 200.0 # Filtered out (delta=40, need >50% of 160=80)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
button:
- platform: template
name: "Test Filter Min"
@@ -178,3 +212,9 @@ button:
id: btn_filter_zero_delta
on_press:
- script.execute: test_filter_zero_delta
- platform: template
name: "Test Filter Percentage"
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage
@@ -0,0 +1,47 @@
esphome:
name: varint-5byte-test
# Define areas and devices - device_ids will be FNV hashes > 2^28,
# requiring 5-byte varint encoding that exercises the 32-bit parse boundary.
areas:
- id: test_area
name: Test Area
devices:
- id: sub_device_one
name: Sub Device One
area_id: test_area
- id: sub_device_two
name: Sub Device Two
area_id: test_area
host:
api:
logger:
# Switches on sub-devices so we can send commands with large device_id varints
switch:
- platform: template
name: Device Switch
device_id: sub_device_one
id: device_switch_one
optimistic: true
turn_on_action:
- logger.log: "Switch one on"
turn_off_action:
- logger.log: "Switch one off"
- platform: template
name: Device Switch
device_id: sub_device_two
id: device_switch_two
optimistic: true
turn_on_action:
- logger.log: "Switch two on"
turn_off_action:
- logger.log: "Switch two off"
sensor:
- platform: template
name: Device Sensor
device_id: sub_device_one
lambda: return 42.0;
update_interval: 0.1s
@@ -270,6 +270,14 @@ async def test_alarm_control_panel_state_transitions(
# The chime_sensor has chime: true, so opening it while disarmed
# should trigger on_chime callback
# Set up future for the on_ready from opening the chime sensor
# (alarm becomes "not ready" when chime sensor opens).
# We must wait for this BEFORE creating the close future, otherwise
# the open event's log can arrive late and resolve the close future,
# causing the test to proceed before the chime close is processed.
ready_after_chime_open: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_open)
# We're currently DISARMED - open the chime sensor
client.switch_command(chime_switch_info.key, True)
@@ -279,11 +287,18 @@ async def test_alarm_control_panel_state_transitions(
except TimeoutError:
pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}")
# Close the chime sensor and wait for alarm to become ready again
# We need to wait for this transition before testing door sensor,
# otherwise there's a race where the door sensor state change could
# arrive before the chime sensor state change, leaving the alarm in
# a continuous "not ready" state with no on_ready callback fired.
# Wait for the on_ready from the chime sensor opening
try:
await asyncio.wait_for(ready_after_chime_open, timeout=2.0)
except TimeoutError:
pytest.fail(
f"on_ready callback not fired when chime sensor opened. "
f"Log lines: {log_lines[-20:]}"
)
# Now create the future for the close event and close the sensor.
# Since we waited for the open event above, the close event's
# on_ready log cannot be confused with the open event's.
ready_after_chime_close: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_close)
+116 -57
View File
@@ -4,11 +4,80 @@ from __future__ import annotations
import asyncio
from aioesphomeapi import BinarySensorState, EntityState, SensorState, TextSensorState
from aioesphomeapi import (
AlarmControlPanelEntityState,
BinarySensorState,
CoverState,
DateState,
DateTimeState,
EntityState,
FanState,
LightState,
LockEntityState,
NumberState,
SelectState,
SensorState,
SwitchState,
TextSensorState,
TextState,
TimeState,
ValveState,
WaterHeaterState,
)
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# Mapping of entity name to device name for all entities with device_id
ENTITY_TO_DEVICE = {
# Original entities
"Temperature": "Temperature Monitor",
"Humidity": "Humidity Monitor",
"Motion Detected": "Motion Sensor",
"Temperature Monitor Power": "Temperature Monitor",
"Temperature Status": "Temperature Monitor",
"Motion Light": "Motion Sensor",
# New entity types
"Garage Door": "Motion Sensor",
"Ceiling Fan": "Humidity Monitor",
"Front Door Lock": "Motion Sensor",
"Target Temperature": "Temperature Monitor",
"Mode Select": "Humidity Monitor",
"Device Label": "Temperature Monitor",
"Water Valve": "Humidity Monitor",
"Test Boiler": "Temperature Monitor",
"House Alarm": "Motion Sensor",
"Schedule Date": "Temperature Monitor",
"Schedule Time": "Humidity Monitor",
"Schedule DateTime": "Motion Sensor",
"Doorbell": "Motion Sensor",
}
# Entities without device_id (should have device_id 0)
NO_DEVICE_ENTITIES = {"No Device Sensor"}
# State types that should have non-zero device_id, mapped by their aioesphomeapi class
EXPECTED_STATE_TYPES = [
(SensorState, "sensor"),
(BinarySensorState, "binary_sensor"),
(SwitchState, "switch"),
(TextSensorState, "text_sensor"),
(LightState, "light"),
(CoverState, "cover"),
(FanState, "fan"),
(LockEntityState, "lock"),
(NumberState, "number"),
(SelectState, "select"),
(TextState, "text"),
(ValveState, "valve"),
(WaterHeaterState, "water_heater"),
(AlarmControlPanelEntityState, "alarm_control_panel"),
(DateState, "date"),
(TimeState, "time"),
(DateTimeState, "datetime"),
# Event is stateless (no initial state sent on subscribe)
]
@pytest.mark.asyncio
async def test_device_id_in_state(
@@ -40,34 +109,35 @@ async def test_device_id_in_state(
entity_device_mapping: dict[int, int] = {}
for entity in all_entities:
# All entities have name and key attributes
if entity.name == "Temperature":
entity_device_mapping[entity.key] = device_ids["Temperature Monitor"]
elif entity.name == "Humidity":
entity_device_mapping[entity.key] = device_ids["Humidity Monitor"]
elif entity.name == "Motion Detected":
entity_device_mapping[entity.key] = device_ids["Motion Sensor"]
elif entity.name in {"Temperature Monitor Power", "Temperature Status"}:
entity_device_mapping[entity.key] = device_ids["Temperature Monitor"]
elif entity.name == "Motion Light":
entity_device_mapping[entity.key] = device_ids["Motion Sensor"]
elif entity.name == "No Device Sensor":
# Entity without device_id should have device_id 0
if entity.name in ENTITY_TO_DEVICE:
expected_device = ENTITY_TO_DEVICE[entity.name]
entity_device_mapping[entity.key] = device_ids[expected_device]
elif entity.name in NO_DEVICE_ENTITIES:
entity_device_mapping[entity.key] = 0
assert len(entity_device_mapping) >= 6, (
f"Expected at least 6 mapped entities, got {len(entity_device_mapping)}"
expected_count = len(ENTITY_TO_DEVICE) + len(NO_DEVICE_ENTITIES)
assert len(entity_device_mapping) >= expected_count, (
f"Expected at least {expected_count} mapped entities, "
f"got {len(entity_device_mapping)}. "
f"Missing: {set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES - {e.name for e in all_entities}}"
)
# Subscribe to states and wait for all mapped entities
# Event entities are stateless (no initial state on subscribe),
# so exclude them from the expected count
stateless_keys = {e.key for e in all_entities if e.name == "Doorbell"}
stateful_count = len(entity_device_mapping) - len(
stateless_keys & entity_device_mapping.keys()
)
# Subscribe to states
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
# Check if we have states for all mapped entities
if len(states) >= len(entity_device_mapping) and not states_future.done():
if state.key in entity_device_mapping:
states[state.key] = state
if len(states) >= stateful_count and not states_future.done():
states_future.set_result(True)
client.subscribe_states(on_state)
@@ -76,9 +146,16 @@ async def test_device_id_in_state(
try:
await asyncio.wait_for(states_future, timeout=10.0)
except TimeoutError:
received_names = {e.name for e in all_entities if e.key in states}
missing_names = (
(set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES)
- received_names
- {"Doorbell"}
)
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Received {len(states)} states, expected {len(entity_device_mapping)}"
f"Received {len(states)} states. "
f"Missing: {missing_names}"
)
# Verify each state has the correct device_id
@@ -86,51 +163,33 @@ async def test_device_id_in_state(
for key, expected_device_id in entity_device_mapping.items():
if key in states:
state = states[key]
entity_name = next(
(e.name for e in all_entities if e.key == key), f"key={key}"
)
assert state.device_id == expected_device_id, (
f"State for key {key} has device_id {state.device_id}, "
f"expected {expected_device_id}"
f"State for '{entity_name}' (type={type(state).__name__}) "
f"has device_id {state.device_id}, expected {expected_device_id}"
)
verified_count += 1
assert verified_count >= 6, (
f"Only verified {verified_count} states, expected at least 6"
# All stateful entities should be verified (everything except Doorbell event)
expected_verified = expected_count - 1 # exclude Doorbell
assert verified_count >= expected_verified, (
f"Only verified {verified_count} states, expected at least {expected_verified}"
)
# Test specific state types to ensure device_id is present
# Find a sensor state with device_id
sensor_state = next(
(
# Verify each expected state type has at least one instance with non-zero device_id
for state_type, type_name in EXPECTED_STATE_TYPES:
matching = [
s
for s in states.values()
if isinstance(s, SensorState)
and isinstance(s.state, float)
and s.device_id != 0
),
None,
)
assert sensor_state is not None, "No sensor state with device_id found"
assert sensor_state.device_id > 0, "Sensor state should have non-zero device_id"
# Find a binary sensor state
binary_sensor_state = next(
(s for s in states.values() if isinstance(s, BinarySensorState)),
None,
)
assert binary_sensor_state is not None, "No binary sensor state found"
assert binary_sensor_state.device_id > 0, (
"Binary sensor state should have non-zero device_id"
)
# Find a text sensor state
text_sensor_state = next(
(s for s in states.values() if isinstance(s, TextSensorState)),
None,
)
assert text_sensor_state is not None, "No text sensor state found"
assert text_sensor_state.device_id > 0, (
"Text sensor state should have non-zero device_id"
)
if isinstance(s, state_type) and s.device_id != 0
]
assert matching, (
f"No {type_name} state (type={state_type.__name__}) "
f"with non-zero device_id found"
)
# Verify the "No Device Sensor" has device_id = 0
no_device_key = next(
+26 -1
View File
@@ -24,12 +24,14 @@ async def test_sensor_filters_delta(
"filter_max": [],
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
}
filter_min_done = loop.create_future()
filter_max_done = loop.create_future()
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
@@ -66,6 +68,12 @@ async def test_sensor_filters_delta(
and not filter_zero_delta_done.done()
):
filter_zero_delta_done.set_result(True)
elif (
sensor_name == "filter_percentage"
and len(sensor_values[sensor_name]) == 3
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -80,6 +88,7 @@ async def test_sensor_filters_delta(
"filter_max": "Filter Max",
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
},
)
@@ -98,13 +107,14 @@ async def test_sensor_filters_delta(
"Test Filter Max": "filter_max",
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}"
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -161,3 +171,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_zero_delta"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}"
)
# Test 5: Percentage (delta: 50%)
sensor_values["filter_percentage"].clear()
client.button_command(buttons["filter_percentage"])
try:
await asyncio.wait_for(filter_percentage_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 5 timed out. Values: {sensor_values['filter_percentage']}"
)
expected = [100.0, 160.0, 250.0]
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)
@@ -0,0 +1,120 @@
"""Integration test for 5-byte varint parsing of device_id fields.
Device IDs are FNV hashes (uint32) that frequently exceed 2^28 (268435456),
requiring 5 varint bytes. This test verifies that:
1. The firmware correctly decodes 5-byte varint device_id in incoming commands
2. The firmware correctly encodes large device_id values in state responses
3. Switch commands with large device_id reach the correct entity
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityState, SwitchInfo, SwitchState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_varint_five_byte_device_id(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that device_id values requiring 5-byte varints parse correctly."""
async with run_compiled(yaml_config), api_client_connected() as client:
device_info = await client.device_info()
devices = device_info.devices
assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}"
# Verify at least one device_id exceeds the 4-byte varint boundary (2^28)
large_ids = [d for d in devices if d.device_id >= (1 << 28)]
assert len(large_ids) > 0, (
"Expected at least one device_id >= 2^28 to exercise 5-byte varint path. "
f"Got device_ids: {[d.device_id for d in devices]}"
)
# Get entities
all_entities, _ = await client.list_entities_services()
switch_entities = [e for e in all_entities if isinstance(e, SwitchInfo)]
# Find switches named "Device Switch" — one per sub-device
device_switches = [e for e in switch_entities if e.name == "Device Switch"]
assert len(device_switches) == 2, (
f"Expected 2 'Device Switch' entities, got {len(device_switches)}"
)
# Verify switches have different device_ids matching the sub-devices
switch_device_ids = {s.device_id for s in device_switches}
assert len(switch_device_ids) == 2, "Switches should have different device_ids"
# Subscribe to states and wait for initial states
loop = asyncio.get_running_loop()
states: dict[tuple[int, int], EntityState] = {}
switch_futures: dict[tuple[int, int], asyncio.Future[EntityState]] = {}
initial_done: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
key = (state.device_id, state.key)
states[key] = state
if len(states) >= 3 and not initial_done.done():
initial_done.set_result(True)
if initial_done.done() and key in switch_futures:
fut = switch_futures[key]
if not fut.done() and isinstance(state, SwitchState):
fut.set_result(state)
client.subscribe_states(on_state)
try:
await asyncio.wait_for(initial_done, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Timed out waiting for initial states. Got {len(states)} states"
)
# Verify state responses contain correct large device_id values
for device in devices:
device_states = [
s for (did, _), s in states.items() if did == device.device_id
]
assert len(device_states) > 0, (
f"No states received for device '{device.name}' "
f"(device_id={device.device_id})"
)
# Test switch commands with large device_id varints —
# this is the critical path: the client encodes device_id as a varint
# in the SwitchCommandRequest, and the firmware must decode it correctly.
for switch in device_switches:
state_key = (switch.device_id, switch.key)
# Turn on
switch_futures[state_key] = loop.create_future()
client.switch_command(switch.key, True, device_id=switch.device_id)
try:
await asyncio.wait_for(switch_futures[state_key], timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timed out waiting for switch ON state "
f"(device_id={switch.device_id}, key={switch.key}). "
f"This likely means the firmware failed to decode the "
f"5-byte varint device_id in SwitchCommandRequest."
)
assert states[state_key].state is True
# Turn off
switch_futures[state_key] = loop.create_future()
client.switch_command(switch.key, False, device_id=switch.device_id)
try:
await asyncio.wait_for(switch_futures[state_key], timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timed out waiting for switch OFF state "
f"(device_id={switch.device_id}, key={switch.key})"
)
assert states[state_key].state is False
@@ -30,6 +30,7 @@ esp32_camera:
resolution: 640x480
jpeg_quality: 10
frame_buffer_location: PSRAM
pixel_format: JPEG
on_image:
then:
- lambda: |-
+1 -1
View File
@@ -325,7 +325,7 @@ class TestStatements:
),
(
cg.ProgmemAssignmentExpression(ct.uint16, "foo", "bar"),
'static const uint16_t foo[] PROGMEM = "bar"',
'static constexpr uint16_t foo[] PROGMEM = "bar"',
),
),
)
+112 -1
View File
@@ -656,7 +656,7 @@ def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
# Should raise on the second attempt when _recover_broken=False
# This hits the "if not _recover_broken: raise" path
with (
unittest.mock.patch("esphome.git.shutil.rmtree", side_effect=mock_rmtree),
unittest.mock.patch("esphome.git.rmtree", side_effect=mock_rmtree),
pytest.raises(GitCommandError, match="fatal: unable to write new index file"),
):
git.clone_or_update(
@@ -671,3 +671,114 @@ def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
stash_calls = [c for c in call_list if "stash" in c[0][0]]
# Should have exactly two stash calls
assert len(stash_calls) == 2
def test_clone_or_update_cleans_up_on_failed_ref_fetch(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that a failed ref fetch removes the incomplete clone directory.
When cloning with a specific ref, if `git clone` succeeds but the
subsequent `git fetch <ref>` fails, the clone directory should be
removed so the next attempt starts fresh instead of finding a stale
clone on the default branch.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "pull/123/head"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "clone":
# Simulate successful clone by creating the directory
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
if cmd_type == "fetch":
raise GitCommandError("fatal: couldn't find remote ref pull/123/head")
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
with pytest.raises(GitCommandError, match="couldn't find remote ref"):
git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# The incomplete clone directory should have been removed
assert not repo_dir.exists()
# Verify clone was attempted then fetch failed
call_list = mock_run_git_command.call_args_list
clone_calls = [c for c in call_list if "clone" in c[0][0]]
assert len(clone_calls) == 1
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
assert len(fetch_calls) == 1
def test_clone_or_update_stale_clone_is_retried_after_cleanup(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that after cleanup, a subsequent call does a fresh clone.
This is the full scenario: first call fails at fetch (directory cleaned up),
second call sees no directory and clones fresh.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "pull/123/head"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
call_count = {"clone": 0, "fetch": 0}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "clone":
call_count["clone"] += 1
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
if cmd_type == "fetch":
call_count["fetch"] += 1
if call_count["fetch"] == 1:
# First fetch fails
raise GitCommandError("fatal: couldn't find remote ref pull/123/head")
# Second fetch succeeds
return ""
if cmd_type == "reset":
return ""
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
# First call: clone succeeds, fetch fails, directory cleaned up
with pytest.raises(GitCommandError, match="couldn't find remote ref"):
git.clone_or_update(url=url, ref=ref, refresh=refresh, domain=domain)
assert not repo_dir.exists()
# Second call: fresh clone + fetch succeeds
result_dir, _ = git.clone_or_update(
url=url, ref=ref, refresh=refresh, domain=domain
)
assert result_dir == repo_dir
assert repo_dir.exists()
assert call_count["clone"] == 2
assert call_count["fetch"] == 2
+6
View File
@@ -2951,6 +2951,7 @@ def test_run_miniterm_batches_lines_with_same_timestamp(
mock_serial = MockSerial([chunk, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
@@ -2989,6 +2990,7 @@ def test_run_miniterm_different_chunks_different_timestamps(
mock_serial = MockSerial([chunk1, chunk2, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
@@ -3019,6 +3021,7 @@ def test_run_miniterm_handles_split_lines() -> None:
mock_serial = MockSerial([chunk1, chunk2, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
@@ -3057,6 +3060,7 @@ def test_run_miniterm_backtrace_state_maintained() -> None:
mock_serial = MockSerial([backtrace_chunk, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
@@ -3122,6 +3126,7 @@ def test_run_miniterm_handles_empty_reads(
mock_serial = MockSerial([b"", chunk, b"", MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
@@ -3194,6 +3199,7 @@ def test_run_miniterm_buffer_limit_prevents_unbounded_growth() -> None:
mock_serial = MockSerial([large_data_no_newline, final_line, MOCK_SERIAL_END])
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32}
config = {
CONF_LOGGER: {
CONF_BAUD_RATE: 115200,
+195 -1
View File
@@ -6,7 +6,7 @@ import os
from pathlib import Path
import shutil
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, Mock, call, patch
import pytest
@@ -673,6 +673,200 @@ def test_process_stacktrace_bad_alloc(
assert state is False
def test_patch_file_downloader_succeeds_first_try() -> None:
"""Test patch_file_downloader succeeds on first attempt."""
mock_exception_cls = type("PackageException", (Exception,), {})
original_init = MagicMock()
with patch.dict(
"sys.modules",
{
"platformio": MagicMock(),
"platformio.package": MagicMock(),
"platformio.package.download": SimpleNamespace(
FileDownloader=type("FileDownloader", (), {"__init__": original_init})
),
"platformio.package.exception": SimpleNamespace(
PackageException=mock_exception_cls
),
},
):
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
instance = object.__new__(FileDownloader)
FileDownloader.__init__(instance, "http://example.com/file.zip")
original_init.assert_called_once()
def test_patch_file_downloader_retries_on_failure() -> None:
"""Test patch_file_downloader retries with backoff on PackageException."""
mock_exception_cls = type("PackageException", (Exception,), {})
call_count = 0
def failing_init(self, *args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise mock_exception_cls(f"502 error attempt {call_count}")
with (
patch.dict(
"sys.modules",
{
"platformio": MagicMock(),
"platformio.package": MagicMock(),
"platformio.package.download": SimpleNamespace(
FileDownloader=type(
"FileDownloader", (), {"__init__": failing_init}
)
),
"platformio.package.exception": SimpleNamespace(
PackageException=mock_exception_cls
),
},
),
patch("time.sleep") as mock_sleep,
):
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
instance = object.__new__(FileDownloader)
FileDownloader.__init__(instance, "http://example.com/file.zip")
# Should have been called 3 times (2 failures + 1 success)
assert call_count == 3
# Should have slept with exponential backoff: 2s, 4s
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(2)
mock_sleep.assert_any_call(4)
def test_patch_file_downloader_raises_after_max_retries() -> None:
"""Test patch_file_downloader raises after exhausting all retries."""
mock_exception_cls = type("PackageException", (Exception,), {})
def always_failing_init(self, *args, **kwargs):
raise mock_exception_cls("502 error")
with (
patch.dict(
"sys.modules",
{
"platformio": MagicMock(),
"platformio.package": MagicMock(),
"platformio.package.download": SimpleNamespace(
FileDownloader=type(
"FileDownloader", (), {"__init__": always_failing_init}
)
),
"platformio.package.exception": SimpleNamespace(
PackageException=mock_exception_cls
),
},
),
patch("time.sleep") as mock_sleep,
):
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
instance = object.__new__(FileDownloader)
with pytest.raises(mock_exception_cls, match="502 error"):
FileDownloader.__init__(instance, "http://example.com/file.zip")
# Should have slept 4 times (before attempts 2-5), not on final attempt
assert mock_sleep.call_count == 4
mock_sleep.assert_has_calls([call(2), call(4), call(8), call(16)])
def test_patch_file_downloader_closes_session_and_response_between_retries() -> None:
"""Test patch_file_downloader closes HTTP session and response between retries."""
mock_exception_cls = type("PackageException", (Exception,), {})
mock_session = MagicMock()
mock_response = MagicMock()
call_count = 0
def failing_init_with_session(self, *args, **kwargs):
nonlocal call_count
call_count += 1
self._http_session = mock_session
self._http_response = mock_response
if call_count < 2:
raise mock_exception_cls("502 error")
with (
patch.dict(
"sys.modules",
{
"platformio": MagicMock(),
"platformio.package": MagicMock(),
"platformio.package.download": SimpleNamespace(
FileDownloader=type(
"FileDownloader",
(),
{"__init__": failing_init_with_session},
)
),
"platformio.package.exception": SimpleNamespace(
PackageException=mock_exception_cls
),
},
),
patch("time.sleep"),
):
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
instance = object.__new__(FileDownloader)
FileDownloader.__init__(instance, "http://example.com/file.zip")
# Both response and session should have been closed between retries
mock_response.close.assert_called_once()
mock_session.close.assert_called_once()
def test_patch_file_downloader_idempotent() -> None:
"""Test patch_file_downloader does not stack wrappers when called multiple times."""
mock_exception_cls = type("PackageException", (Exception,), {})
call_count = 0
def counting_init(self, *args, **kwargs):
nonlocal call_count
call_count += 1
with patch.dict(
"sys.modules",
{
"platformio": MagicMock(),
"platformio.package": MagicMock(),
"platformio.package.download": SimpleNamespace(
FileDownloader=type("FileDownloader", (), {"__init__": counting_init})
),
"platformio.package.exception": SimpleNamespace(
PackageException=mock_exception_cls
),
},
):
# Patch multiple times
platformio_api.patch_file_downloader()
platformio_api.patch_file_downloader()
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
instance = object.__new__(FileDownloader)
FileDownloader.__init__(instance, "http://example.com/file.zip")
# Should only be called once, not 3 times from stacked wrappers
assert call_count == 1
def test_platformio_log_filter_allows_non_platformio_messages() -> None:
"""Test that non-platformio logger messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()