Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy

This commit is contained in:
kbx81
2026-02-23 18:01:56 -06:00
247 changed files with 4516 additions and 1935 deletions
@@ -15,8 +15,13 @@ 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
i2c:
sda: GPIO7
scl: GPIO8
@@ -119,9 +119,11 @@ 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);" 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 "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"
+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}
@@ -1,5 +1,6 @@
esp32:
variant: esp32p4
engineering_sample: true
flash_size: 32MB
cpu_frequency: 400MHz
framework:
+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:
+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
@@ -5,4 +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
@@ -66,12 +66,12 @@ 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
@@ -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);
@@ -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"
+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(
+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"',
),
),
)
+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()