mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
Merge branch 'store-yaml-helpers-core' into store-yaml-firmware
This commit is contained in:
+12
@@ -16,4 +16,16 @@ void WakeTestComponent::start_async_wake() {
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void WakeTestComponent::start_async_timeout(uint32_t delay_ms) {
|
||||
const uint32_t start_time = millis();
|
||||
std::thread([this, start_time, delay_ms] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
const int start_loop_count = this->get_loop_count();
|
||||
this->set_timeout(delay_ms, [this, start_time, start_loop_count, delay_ms] {
|
||||
ESP_LOGI(TAG, "SCHEDULER_WAKE_RESULT delay=%u elapsed=%u loop_delta=%d", static_cast<unsigned int>(delay_ms),
|
||||
static_cast<unsigned int>(millis() - start_time), this->get_loop_count() - start_loop_count);
|
||||
});
|
||||
}).detach();
|
||||
}
|
||||
|
||||
} // namespace esphome::wake_test_component
|
||||
|
||||
+4
@@ -18,6 +18,10 @@ class WakeTestComponent : public Component {
|
||||
// loop_interval_ has been raised high enough to gate it off otherwise.
|
||||
void start_async_wake();
|
||||
|
||||
// Spawn a detached thread that inserts a timeout (or a defer when delay_ms
|
||||
// is 0) while the main loop sleeps, and report how soon it ran.
|
||||
void start_async_timeout(uint32_t delay_ms);
|
||||
|
||||
float get_setup_priority() const override { return setup_priority::DATA; }
|
||||
|
||||
protected:
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
esphome:
|
||||
name: lvgl-color-picker-render
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
actions:
|
||||
# A snapshot never writes over an existing file, so the test supplies a new name each time.
|
||||
- action: take_screenshot
|
||||
variables:
|
||||
name: string
|
||||
then:
|
||||
- snapshot.take:
|
||||
id: picker_display
|
||||
filename: !lambda return name;
|
||||
- action: set_orange
|
||||
then:
|
||||
- lvgl.color_picker.update:
|
||||
id: picker_sliders
|
||||
color: 0xFF8800
|
||||
- lvgl.color_picker.update:
|
||||
id: picker_wheel
|
||||
color: 0xFF8800
|
||||
# White has no saturation, which a hue-only picker could never change again
|
||||
- lvgl.color_picker.update:
|
||||
id: picker_hue
|
||||
color: 0xFFFFFF
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
display:
|
||||
- platform: snapshot
|
||||
id: picker_display
|
||||
auto_clear_enabled: false
|
||||
dimensions:
|
||||
width: 680
|
||||
height: 480
|
||||
|
||||
lvgl:
|
||||
displays: picker_display
|
||||
default_font: montserrat_14
|
||||
widgets:
|
||||
- obj:
|
||||
width: 100%
|
||||
height: 100%
|
||||
bg_color: 0x404040
|
||||
bg_opa: cover
|
||||
border_width: 0
|
||||
radius: 0
|
||||
pad_all: 4
|
||||
layout:
|
||||
type: flex
|
||||
flex_flow: row_wrap
|
||||
pad_row: 4
|
||||
pad_column: 4
|
||||
widgets:
|
||||
- color_picker:
|
||||
id: picker_sliders
|
||||
width: 196
|
||||
color: 0x3366CC
|
||||
- color_picker:
|
||||
id: picker_wheel
|
||||
width: 196
|
||||
wheel: true
|
||||
color: 0x3366CC
|
||||
# Sized from the text font
|
||||
- color_picker:
|
||||
id: picker_content
|
||||
color: 0x3366CC
|
||||
# No red, green and blue bars beside the swatch
|
||||
- color_picker:
|
||||
id: picker_hsv
|
||||
width: 196
|
||||
sliders: hsv
|
||||
color: 0x3366CC
|
||||
# Only the hue bar
|
||||
- color_picker:
|
||||
id: picker_hue
|
||||
width: 196
|
||||
sliders: hue
|
||||
color: 0x3366CC
|
||||
@@ -0,0 +1,31 @@
|
||||
esphome:
|
||||
name: scheduler-background-wake
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- lambda: |-
|
||||
App.set_loop_interval(5000);
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: INFO
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
components: [wake_test_component]
|
||||
|
||||
wake_test_component:
|
||||
id: wake_test
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Start Scheduler Timeout
|
||||
on_press:
|
||||
- lambda: id(wake_test)->start_async_timeout(100);
|
||||
- platform: template
|
||||
name: Start Scheduler Defer
|
||||
on_press:
|
||||
- lambda: id(wake_test)->start_async_timeout(0);
|
||||
@@ -0,0 +1,67 @@
|
||||
esphome:
|
||||
name: test-timeout-throttle
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Source"
|
||||
id: source_sensor
|
||||
accuracy_decimals: 2
|
||||
|
||||
# The default radar pair; codegen merges it into one TimeoutThrottleFilter
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Merged"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 200ms
|
||||
value: last
|
||||
- throttle_with_priority: 200ms
|
||||
|
||||
# Same chain with an identity filter in between, which keeps the two filter objects
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Reference"
|
||||
filters:
|
||||
- timeout:
|
||||
timeout: 200ms
|
||||
value: last
|
||||
- multiply: 1.0
|
||||
- throttle_with_priority: 200ms
|
||||
|
||||
script:
|
||||
- id: drive
|
||||
then:
|
||||
# Burst faster than the period: first passes, the rest are dropped, the timeout republishes the last
|
||||
- sensor.template.publish: {id: source_sensor, state: 1.0}
|
||||
- delay: 30ms
|
||||
- sensor.template.publish: {id: source_sensor, state: 2.0}
|
||||
- delay: 30ms
|
||||
- sensor.template.publish: {id: source_sensor, state: 3.0}
|
||||
- delay: 450ms
|
||||
# NaN inside a burst always passes
|
||||
- sensor.template.publish: {id: source_sensor, state: 4.0}
|
||||
- delay: 30ms
|
||||
- sensor.template.publish: {id: source_sensor, state: !lambda "return NAN;"}
|
||||
- delay: 30ms
|
||||
- sensor.template.publish: {id: source_sensor, state: 5.0}
|
||||
- delay: 450ms
|
||||
# Values right around the period
|
||||
- sensor.template.publish: {id: source_sensor, state: 6.0}
|
||||
- delay: 250ms
|
||||
- sensor.template.publish: {id: source_sensor, state: 7.0}
|
||||
- delay: 190ms
|
||||
- sensor.template.publish: {id: source_sensor, state: 8.0}
|
||||
- delay: 600ms
|
||||
- sensor.template.publish: {id: source_sensor, state: 99.0}
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Drive"
|
||||
on_press:
|
||||
- script.execute: drive
|
||||
@@ -0,0 +1,40 @@
|
||||
esphome:
|
||||
name: status-momentary-test
|
||||
|
||||
host:
|
||||
api:
|
||||
actions:
|
||||
- action: momentary_warning
|
||||
variables:
|
||||
length: int
|
||||
then:
|
||||
- lambda: "id(probe)->status_momentary_warning(length);"
|
||||
- action: momentary_error
|
||||
variables:
|
||||
length: int
|
||||
then:
|
||||
- lambda: "id(probe)->status_momentary_error(length);"
|
||||
- action: momentary_warning_message
|
||||
then:
|
||||
- lambda: 'id(probe)->status_momentary_warning(LOG_STR("probe warning reason"), 300);'
|
||||
- action: momentary_error_message
|
||||
then:
|
||||
- lambda: 'id(probe)->status_momentary_error(LOG_STR("probe error reason"), 300);'
|
||||
|
||||
logger:
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: probe
|
||||
update_interval: 24h
|
||||
lambda: return 1.0;
|
||||
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: Probe Warning
|
||||
id: probe_warning
|
||||
lambda: return id(probe)->status_has_warning();
|
||||
- platform: template
|
||||
name: Probe Error
|
||||
id: probe_error
|
||||
lambda: return id(probe)->status_has_error();
|
||||
@@ -0,0 +1,74 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2412-footer
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 256000
|
||||
# Both answers hold the footer 04 03 02 01 inside the gate bytes, so the parser must read on to the real footer
|
||||
responses:
|
||||
# Gates 5 to 8 hold 04 03 02 01
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x12, 0x00,
|
||||
0x13, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x23, 0x23, 0x23, 0x23, 0x04, 0x03,
|
||||
0x02, 0x01, 0x19, 0x19, 0x19, 0x19, 0x19,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
# Gates 10 to 13 hold 04 03 02 01, right before the real footer
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x14, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA,
|
||||
0x12, 0x00,
|
||||
0x14, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x23, 0x23, 0x23, 0x23, 0x19, 0x19,
|
||||
0x19, 0x19, 0x19, 0x04, 0x03, 0x02, 0x01,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
]
|
||||
|
||||
ld2412:
|
||||
id: ld2412_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
# Only 2 of the 14 gates have a number; gate 5 holds a footer byte in the motion answer
|
||||
number:
|
||||
- platform: ld2412
|
||||
ld2412_id: ld2412_dev
|
||||
gate_0:
|
||||
move_threshold:
|
||||
name: "Gate 0 Move Threshold"
|
||||
still_threshold:
|
||||
name: "Gate 0 Still Threshold"
|
||||
gate_5:
|
||||
move_threshold:
|
||||
name: "Gate 5 Move Threshold"
|
||||
still_threshold:
|
||||
name: "Gate 5 Still Threshold"
|
||||
|
||||
button:
|
||||
- platform: ld2412
|
||||
ld2412_id: ld2412_dev
|
||||
factory_reset:
|
||||
name: "Factory Reset"
|
||||
@@ -0,0 +1,61 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2412-regiveup
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Only the third motion query is answered: the setup query and the first write's re-query go unanswered,
|
||||
# so that write is given up. The still query is never answered
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 256000
|
||||
on_tx:
|
||||
- then:
|
||||
- lambda: |-
|
||||
// The length and command word arrive as one write of their own
|
||||
static const std::vector<uint8_t> MOTION_QUERY = {0x02, 0x00, 0x13, 0x00};
|
||||
static int motion_queries = 0;
|
||||
if (data != MOTION_QUERY || ++motion_queries != 3) {
|
||||
return;
|
||||
}
|
||||
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
|
||||
0xFD, 0xFC, 0xFB, 0xFA, // header
|
||||
0x12, 0x00, // length 18
|
||||
0x13, 0x01, // answer to command 0x0013
|
||||
0x00, 0x00, // status: success
|
||||
0x19, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, // gates 0 to 6
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, // gates 7 to 13
|
||||
0x04, 0x03, 0x02, 0x01, // footer
|
||||
});
|
||||
|
||||
ld2412:
|
||||
id: ld2412_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
number:
|
||||
- platform: ld2412
|
||||
ld2412_id: ld2412_dev
|
||||
gate_0:
|
||||
move_threshold:
|
||||
name: "Gate 0 Move Threshold"
|
||||
still_threshold:
|
||||
name: "Gate 0 Still Threshold"
|
||||
gate_5:
|
||||
move_threshold:
|
||||
name: "Gate 5 Move Threshold"
|
||||
still_threshold:
|
||||
name: "Gate 5 Still Threshold"
|
||||
@@ -0,0 +1,72 @@
|
||||
esphome:
|
||||
name: uart-mock-ld2412-truncated
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 256000
|
||||
responses:
|
||||
# The motion answer is two gate bytes short, so the parser reads on into the next acknowledgement
|
||||
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01]
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA, # motion header
|
||||
0x12, 0x00, # length 18, but only 16 bytes follow
|
||||
0x13, 0x01, # answer to command 0x0013
|
||||
0x00, 0x00, # status: success
|
||||
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, # gates 0 to 5
|
||||
0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, # gates 6 to 11, gates 12 and 13 are missing
|
||||
0x04, 0x03, 0x02, 0x01, # motion footer
|
||||
0xFD, 0xFC, 0xFB, 0xFA, # next frame: the config-mode end acknowledgement
|
||||
0x04, 0x00, # length 4
|
||||
0xFE, 0x01, # answer to command 0x00FE
|
||||
0x00, 0x00, # status: success
|
||||
0x04, 0x03, 0x02, 0x01, # its footer; the merged 40-byte frame gets dropped
|
||||
]
|
||||
# A complete answer after setup proves the parser recovered
|
||||
injections:
|
||||
- delay: 1500ms
|
||||
inject_rx:
|
||||
[
|
||||
0xFD, 0xFC, 0xFB, 0xFA, # header
|
||||
0x12, 0x00, # length 18
|
||||
0x13, 0x01, # answer to command 0x0013
|
||||
0x00, 0x00, # status: success
|
||||
0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, # gates 0 to 6
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, # gates 7 to 13
|
||||
0x04, 0x03, 0x02, 0x01, # footer
|
||||
]
|
||||
|
||||
ld2412:
|
||||
id: ld2412_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
number:
|
||||
- platform: ld2412
|
||||
ld2412_id: ld2412_dev
|
||||
gate_0:
|
||||
move_threshold:
|
||||
name: "Gate 0 Move Threshold"
|
||||
still_threshold:
|
||||
name: "Gate 0 Still Threshold"
|
||||
gate_5:
|
||||
move_threshold:
|
||||
name: "Gate 5 Move Threshold"
|
||||
still_threshold:
|
||||
name: "Gate 5 Still Threshold"
|
||||
@@ -0,0 +1,153 @@
|
||||
esphome:
|
||||
name: uart-mock-ufm01
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
actions:
|
||||
- action: clear_accumulated_flow
|
||||
then:
|
||||
- ufm01.clear_accumulated_flow: ufm01_dev
|
||||
- lambda: |-
|
||||
id(clear_count) += 1;
|
||||
id(clear_completions).publish_state(id(clear_count));
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy ufm01's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 2400
|
||||
port: /dev/null
|
||||
|
||||
globals:
|
||||
# The simulated meter streams active frames only after SET_ACTIVE_MODE
|
||||
- id: streaming
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
# Set by CLEAR_ACCUMULATED_FLOW; later frames report zero accumulated flow
|
||||
- id: cleared
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
- id: clear_count
|
||||
type: int
|
||||
initial_value: "0"
|
||||
|
||||
# Simulated UFM-01: every command is one 7 byte write
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 2400
|
||||
parity: EVEN
|
||||
on_tx:
|
||||
- then:
|
||||
- lambda: |-
|
||||
if (id(silent).state || data.size() != 7) {
|
||||
return;
|
||||
}
|
||||
static const std::vector<uint8_t> ACK = {0xE5};
|
||||
switch (data[3]) {
|
||||
case 0x5D: // RESET_DEVICE
|
||||
id(streaming) = false;
|
||||
id(mock_uart).inject_to_rx_buffer(ACK);
|
||||
break;
|
||||
case 0x5C: // SET_ACTIVE_MODE (00) or SET_PASSIVE_MODE (01)
|
||||
id(streaming) = data[4] == 0x00;
|
||||
id(mock_uart).inject_to_rx_buffer(ACK);
|
||||
break;
|
||||
case 0x5A: // CLEAR_ACCUMULATED_FLOW
|
||||
id(cleared) = true;
|
||||
id(mock_uart).inject_to_rx_buffer(ACK);
|
||||
break;
|
||||
case 0x5B: // READ_SENSOR_DATA without meter ID
|
||||
if (data[4] != 0x0F) {
|
||||
break;
|
||||
}
|
||||
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
|
||||
0x3C, 0x64, // start
|
||||
0x0A, 0x78, 0x56, 0x02, 0x00, 0x00, 0x00, // accumulated flow 25.678 L
|
||||
0x0B, 0x00, 0x67, 0x05, 0x00, 0x00, // flow 0.567 m3/h
|
||||
0x0D, 0x25, 0x19, 0x00, // temperature 19.25 C
|
||||
0x00, 0x00, // ST1, ST2
|
||||
0x3C, 0x16, // checksum, stop
|
||||
});
|
||||
break;
|
||||
case 0x5E: // GET_SOFTWARE_VERSION: ACK, version 23380315 (BCD, low byte first), checksum, stop
|
||||
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{0xE5, 0x15, 0x03, 0x38, 0x23, 0x73, 0x16});
|
||||
break;
|
||||
}
|
||||
|
||||
interval:
|
||||
- interval: 1s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return id(streaming) && !id(silent).state;"
|
||||
then:
|
||||
- lambda: |-
|
||||
// Meter ID 2307140001 (BCD, low byte first), flow 1.2345 m3/h, temperature 23.5 C
|
||||
std::vector<uint8_t> frame{
|
||||
0x3C, 0x32, // start
|
||||
0x01, 0x00, 0x14, 0x07, 0x23, 0x01, // meter ID, reserved
|
||||
0x0A, 0x45, 0x23, 0x01, 0x00, 0x00, 0x00, // accumulated flow 12.345 L
|
||||
0x0B, 0x50, 0x34, 0x12, 0x00, 0x00, // flow
|
||||
0x0C, 0x00, 0x00, // reserved section
|
||||
0x0D, 0x50, 0x23, 0x00, // temperature
|
||||
0x00, 0x00, // ST1, ST2
|
||||
0x4E, 0x16, // checksum, stop
|
||||
};
|
||||
if (id(cleared)) {
|
||||
// Accumulated flow 0 m3: unit flag 0x1A, zero digits, checksum follows
|
||||
frame[8] = 0x1A;
|
||||
std::fill(frame.begin() + 9, frame.begin() + 15, 0x00);
|
||||
frame[30] = 0xF5;
|
||||
}
|
||||
id(mock_uart).inject_to_rx_buffer(frame);
|
||||
|
||||
ufm01:
|
||||
id: ufm01_dev
|
||||
uart_id: mock_uart
|
||||
|
||||
sensor:
|
||||
- platform: ufm01
|
||||
accumulated_flow:
|
||||
name: "Accumulated Flow"
|
||||
flow:
|
||||
name: "Flow"
|
||||
temperature:
|
||||
name: "Temperature"
|
||||
- platform: template
|
||||
id: clear_completions
|
||||
name: "Clear Completions"
|
||||
update_interval: never
|
||||
|
||||
text_sensor:
|
||||
- platform: ufm01
|
||||
meter_id:
|
||||
name: "Meter ID"
|
||||
software_version:
|
||||
name: "Software Version"
|
||||
|
||||
switch:
|
||||
# The meter stops streaming and answers nothing
|
||||
- platform: template
|
||||
id: silent
|
||||
name: "Silent"
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- lambda: "id(streaming) = false;"
|
||||
|
||||
button:
|
||||
# The stream stops while the meter still answers commands
|
||||
- platform: template
|
||||
name: "Pause Stream"
|
||||
on_press:
|
||||
- lambda: "id(streaming) = false;"
|
||||
- platform: template
|
||||
name: "Resume Stream"
|
||||
on_press:
|
||||
- lambda: "id(streaming) = true;"
|
||||
@@ -0,0 +1,22 @@
|
||||
esphome:
|
||||
name: udp-multicast-test
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
udp:
|
||||
- id: test_udp
|
||||
listen_address: 239.1.2.3
|
||||
addresses:
|
||||
- "239.1.2.3"
|
||||
port:
|
||||
listen_port: UDP_LISTEN_PORT_PLACEHOLDER
|
||||
broadcast_port: UDP_LISTEN_PORT_PLACEHOLDER
|
||||
on_receive:
|
||||
- logger.log:
|
||||
format: "Received UDP: %d bytes"
|
||||
args: [data.size()]
|
||||
@@ -1,169 +1,180 @@
|
||||
{
|
||||
"tests/integration/test_action_concurrent_reentry.py": 12.49,
|
||||
"tests/integration/test_addressable_light_transition.py": 17.27,
|
||||
"tests/integration/test_alarm_control_panel_state_transitions.py": 15.05,
|
||||
"tests/integration/test_api_action_metadata.py": 10.78,
|
||||
"tests/integration/test_api_action_responses.py": 17.68,
|
||||
"tests/integration/test_api_action_timeout.py": 17.48,
|
||||
"tests/integration/test_api_conditional_memory.py": 11.82,
|
||||
"tests/integration/test_api_custom_services.py": 8.17,
|
||||
"tests/integration/test_api_decode_wire_types.py": 17.82,
|
||||
"tests/integration/test_api_empty_message_roundtrip.py": 12.6,
|
||||
"tests/integration/test_api_encode_boundaries.py": 14.83,
|
||||
"tests/integration/test_api_get_time_response_timezone.py": 12.93,
|
||||
"tests/integration/test_api_homeassistant.py": 19.79,
|
||||
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 12.45,
|
||||
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.67,
|
||||
"tests/integration/test_api_list_entities_backpressure.py": 12.69,
|
||||
"tests/integration/test_api_message_size_batching.py": 15.39,
|
||||
"tests/integration/test_api_reboot_timeout.py": 12.25,
|
||||
"tests/integration/test_api_string_lambda.py": 13.84,
|
||||
"tests/integration/test_api_vv_logging.py": 12.98,
|
||||
"tests/integration/test_api_zero_psk_provisioning.py": 19.36,
|
||||
"tests/integration/test_areas_and_devices.py": 8.09,
|
||||
"tests/integration/test_automation_wait_actions.py": 14.26,
|
||||
"tests/integration/test_automations.py": 20.27,
|
||||
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.02,
|
||||
"tests/integration/test_binary_sensor_autorepeat_filter.py": 14.18,
|
||||
"tests/integration/test_binary_sensor_invalidate_state.py": 13.04,
|
||||
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 10.44,
|
||||
"tests/integration/test_build_info.py": 12.48,
|
||||
"tests/integration/test_camera_mock.py": 12.53,
|
||||
"tests/integration/test_climate_control_action.py": 9.7,
|
||||
"tests/integration/test_climate_custom_modes.py": 14.68,
|
||||
"tests/integration/test_continuation_actions.py": 12.16,
|
||||
"tests/integration/test_cover_control_action.py": 11.76,
|
||||
"tests/integration/test_crc8_helper.py": 8.21,
|
||||
"tests/integration/test_device_id_in_state.py": 26.48,
|
||||
"tests/integration/test_duplicate_entities.py": 14.89,
|
||||
"tests/integration/test_entity_icon.py": 15.75,
|
||||
"tests/integration/test_entity_missing_state.py": 9.8,
|
||||
"tests/integration/test_fan_turn_on_action.py": 12.96,
|
||||
"tests/integration/test_fnv1_hash_object_id.py": 2.55,
|
||||
"tests/integration/test_fnv1a_hash.py": 9.37,
|
||||
"tests/integration/test_gpio_expander_cache.py": 12.31,
|
||||
"tests/integration/test_host_logger_thread_safety.py": 7.8,
|
||||
"tests/integration/test_host_mode_basic.py": 11.59,
|
||||
"tests/integration/test_host_mode_batch_delay.py": 8.18,
|
||||
"tests/integration/test_host_mode_climate_basic_state.py": 12.53,
|
||||
"tests/integration/test_host_mode_climate_control.py": 14.3,
|
||||
"tests/integration/test_host_mode_empty_string_options.py": 13.74,
|
||||
"tests/integration/test_host_mode_entity_fields.py": 15.51,
|
||||
"tests/integration/test_host_mode_fan_preset.py": 9.63,
|
||||
"tests/integration/test_host_mode_many_entities.py": 16.22,
|
||||
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 13.26,
|
||||
"tests/integration/test_host_mode_noise_encryption.py": 29.86,
|
||||
"tests/integration/test_host_mode_reconnect.py": 11.7,
|
||||
"tests/integration/test_host_mode_sensor.py": 11.74,
|
||||
"tests/integration/test_host_ota.py": 129.75,
|
||||
"tests/integration/test_host_preferences.py": 8.46,
|
||||
"tests/integration/test_host_preferences_suspend_resume.py": 14.3,
|
||||
"tests/integration/test_improv_serial_uart.py": 15.6,
|
||||
"tests/integration/test_large_message_batching.py": 13.6,
|
||||
"tests/integration/test_legacy_area.py": 11.44,
|
||||
"tests/integration/test_legacy_climate_compat.py": 12.99,
|
||||
"tests/integration/test_legacy_fan_compat.py": 8.75,
|
||||
"tests/integration/test_light_automations.py": 15.38,
|
||||
"tests/integration/test_light_binary_effect_off_phase.py": 23.85,
|
||||
"tests/integration/test_light_calls.py": 16.45,
|
||||
"tests/integration/test_light_constant_brightness.py": 16.87,
|
||||
"tests/integration/test_light_control_action.py": 16.25,
|
||||
"tests/integration/test_light_dim_relative_action.py": 14.03,
|
||||
"tests/integration/test_light_effect_zero_brightness.py": 16.53,
|
||||
"tests/integration/test_light_initial_state.py": 15.16,
|
||||
"tests/integration/test_light_repeat_effect.py": 22.35,
|
||||
"tests/integration/test_light_restore_state.py": 11.68,
|
||||
"tests/integration/test_light_resume_effect.py": 17.07,
|
||||
"tests/integration/test_light_toggle_action.py": 14.35,
|
||||
"tests/integration/test_light_transition_state_publish_interval.py": 47.18,
|
||||
"tests/integration/test_lock_automations.py": 12.65,
|
||||
"tests/integration/test_logger_buffered_recursion_guard.py": 12.2,
|
||||
"tests/integration/test_loop_disable_enable.py": 11.31,
|
||||
"tests/integration/test_loop_interval_decoupling.py": 13.71,
|
||||
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 11.78,
|
||||
"tests/integration/test_lvgl_headless_render.py": 49.4,
|
||||
"tests/integration/test_micros_to_millis.py": 6.71,
|
||||
"tests/integration/test_multi_click_trigger.py": 10.38,
|
||||
"tests/integration/test_multi_device_preferences.py": 16.06,
|
||||
"tests/integration/test_noise_encryption_key_protection.py": 13.43,
|
||||
"tests/integration/test_object_id_api_verification.py": 14.32,
|
||||
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.57,
|
||||
"tests/integration/test_object_id_no_friendly_name.py": 15.59,
|
||||
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 24.62,
|
||||
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 29.88,
|
||||
"tests/integration/test_online_image_bmp.py": 23.67,
|
||||
"tests/integration/test_oversized_payloads.py": 51.21,
|
||||
"tests/integration/test_preference_key_stability.py": 14.85,
|
||||
"tests/integration/test_runtime_stats.py": 11.2,
|
||||
"tests/integration/test_safe_mode_loop_runs.py": 8.22,
|
||||
"tests/integration/test_scheduler_blocking_warning.py": 28.18,
|
||||
"tests/integration/test_scheduler_bulk_cleanup.py": 12.49,
|
||||
"tests/integration/test_scheduler_defer_cancel.py": 9.95,
|
||||
"tests/integration/test_scheduler_defer_cancel_regular.py": 13.32,
|
||||
"tests/integration/test_scheduler_defer_fifo_simple.py": 13.18,
|
||||
"tests/integration/test_scheduler_defer_stress.py": 14.29,
|
||||
"tests/integration/test_scheduler_heap_stress.py": 4.73,
|
||||
"tests/integration/test_scheduler_internal_id_no_collision.py": 14.05,
|
||||
"tests/integration/test_scheduler_interval_reschedule.py": 9.0,
|
||||
"tests/integration/test_scheduler_interval_zero_coerced.py": 3.27,
|
||||
"tests/integration/test_scheduler_null_name.py": 12.94,
|
||||
"tests/integration/test_scheduler_numeric_id_test.py": 11.83,
|
||||
"tests/integration/test_scheduler_pool.py": 9.66,
|
||||
"tests/integration/test_scheduler_rapid_cancellation.py": 3.95,
|
||||
"tests/integration/test_scheduler_recursive_timeout.py": 12.91,
|
||||
"tests/integration/test_scheduler_removed_item_race.py": 11.9,
|
||||
"tests/integration/test_scheduler_self_keyed.py": 13.42,
|
||||
"tests/integration/test_scheduler_simultaneous_callbacks.py": 8.01,
|
||||
"tests/integration/test_scheduler_string_test.py": 10.2,
|
||||
"tests/integration/test_script_array_params.py": 12.99,
|
||||
"tests/integration/test_script_delay_params.py": 14.73,
|
||||
"tests/integration/test_script_queued.py": 16.62,
|
||||
"tests/integration/test_script_queued_idle_loop.py": 11.13,
|
||||
"tests/integration/test_script_wait_on_boot.py": 13.31,
|
||||
"tests/integration/test_sdl_headless_screenshot.py": 14.76,
|
||||
"tests/integration/test_select_stringref_trigger.py": 10.98,
|
||||
"tests/integration/test_sensor_filters_delta.py": 12.63,
|
||||
"tests/integration/test_sensor_filters_ring_buffer.py": 11.13,
|
||||
"tests/integration/test_sensor_filters_sliding_window.py": 40.2,
|
||||
"tests/integration/test_sensor_filters_value_list.py": 10.25,
|
||||
"tests/integration/test_sensor_raw_state.py": 26.04,
|
||||
"tests/integration/test_sensor_timeout_filter.py": 13.81,
|
||||
"tests/integration/test_set_internal_at_boot.py": 14.13,
|
||||
"tests/integration/test_snapshot_display.py": 16.36,
|
||||
"tests/integration/test_socket_ipv4_resolve.py": 11.75,
|
||||
"tests/integration/test_socket_tcp_client_link.py": 9.79,
|
||||
"tests/integration/test_socket_wake_gate_tcp.py": 15.29,
|
||||
"tests/integration/test_status_flags.py": 20.87,
|
||||
"tests/integration/test_strftime_to.py": 10.06,
|
||||
"tests/integration/test_syslog.py": 18.51,
|
||||
"tests/integration/test_tcp_uart_bridge.py": 14.71,
|
||||
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 8.4,
|
||||
"tests/integration/test_template_climate_basic.py": 14.22,
|
||||
"tests/integration/test_template_climate_custom_modes.py": 11.61,
|
||||
"tests/integration/test_template_climate_nonoptimistic.py": 13.47,
|
||||
"tests/integration/test_template_climate_on_control_ordering.py": 13.99,
|
||||
"tests/integration/test_template_climate_publish_all_fields.py": 10.98,
|
||||
"tests/integration/test_template_climate_sensor_push.py": 14.86,
|
||||
"tests/integration/test_template_climate_set_actions.py": 11.79,
|
||||
"tests/integration/test_template_climate_two_point_temperature.py": 14.92,
|
||||
"tests/integration/test_template_text_save.py": 13.35,
|
||||
"tests/integration/test_template_time.py": 10.07,
|
||||
"tests/integration/test_text_command.py": 12.11,
|
||||
"tests/integration/test_text_sensor_raw_state.py": 13.96,
|
||||
"tests/integration/test_uart_mock_ld2410.py": 30.03,
|
||||
"tests/integration/test_uart_mock_ld2412.py": 35.57,
|
||||
"tests/integration/test_uart_mock_ld2420.py": 26.6,
|
||||
"tests/integration/test_uart_mock_ld2450.py": 13.03,
|
||||
"tests/integration/test_uart_mock_modbus.py": 309.49,
|
||||
"tests/integration/test_uart_tcp_bridge.py": 12.67,
|
||||
"tests/integration/test_udp.py": 11.64,
|
||||
"tests/integration/test_use_address_runtime.py": 16.79,
|
||||
"tests/integration/test_valve_control_action.py": 12.76,
|
||||
"tests/integration/test_varint_five_byte_device_id.py": 14.05,
|
||||
"tests/integration/test_wait_until_mid_loop_timing.py": 12.48,
|
||||
"tests/integration/test_wait_until_on_boot.py": 12.54,
|
||||
"tests/integration/test_wait_until_ordering.py": 13.32,
|
||||
"tests/integration/test_wait_until_reentrant_restart.py": 5.89,
|
||||
"tests/integration/test_wake_loop_forces_phase_b.py": 13.8,
|
||||
"tests/integration/test_water_heater_template.py": 24.77
|
||||
"tests/integration/test_action_concurrent_reentry.py": 14.33,
|
||||
"tests/integration/test_addressable_light_transition.py": 18.53,
|
||||
"tests/integration/test_alarm_control_panel_state_transitions.py": 15.5,
|
||||
"tests/integration/test_api_action_metadata.py": 11.08,
|
||||
"tests/integration/test_api_action_responses.py": 15.46,
|
||||
"tests/integration/test_api_action_timeout.py": 16.54,
|
||||
"tests/integration/test_api_conditional_memory.py": 11.84,
|
||||
"tests/integration/test_api_custom_services.py": 13.37,
|
||||
"tests/integration/test_api_decode_wire_types.py": 17.49,
|
||||
"tests/integration/test_api_empty_message_roundtrip.py": 12.35,
|
||||
"tests/integration/test_api_encode_boundaries.py": 10.93,
|
||||
"tests/integration/test_api_get_time_response_timezone.py": 13.5,
|
||||
"tests/integration/test_api_homeassistant.py": 18.7,
|
||||
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96,
|
||||
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76,
|
||||
"tests/integration/test_api_homeassistant_entities.py": 15.12,
|
||||
"tests/integration/test_api_list_entities_backpressure.py": 14.2,
|
||||
"tests/integration/test_api_message_size_batching.py": 13.72,
|
||||
"tests/integration/test_api_outgoing_connection.py": 28.66,
|
||||
"tests/integration/test_api_reboot_timeout.py": 9.01,
|
||||
"tests/integration/test_api_string_lambda.py": 12.16,
|
||||
"tests/integration/test_api_vv_logging.py": 10.14,
|
||||
"tests/integration/test_api_zero_psk_provisioning.py": 18.26,
|
||||
"tests/integration/test_areas_and_devices.py": 10.34,
|
||||
"tests/integration/test_automation_wait_actions.py": 9.93,
|
||||
"tests/integration/test_automations.py": 20.02,
|
||||
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34,
|
||||
"tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03,
|
||||
"tests/integration/test_binary_sensor_invalidate_state.py": 8.43,
|
||||
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39,
|
||||
"tests/integration/test_branch_resume_async.py": 16.64,
|
||||
"tests/integration/test_build_info.py": 12.41,
|
||||
"tests/integration/test_camera_mock.py": 13.29,
|
||||
"tests/integration/test_climate_control_action.py": 15.18,
|
||||
"tests/integration/test_climate_custom_modes.py": 14.12,
|
||||
"tests/integration/test_continuation_actions.py": 14.06,
|
||||
"tests/integration/test_cover_control_action.py": 9.59,
|
||||
"tests/integration/test_crc8_helper.py": 11.51,
|
||||
"tests/integration/test_device_id_in_state.py": 18.41,
|
||||
"tests/integration/test_duplicate_entities.py": 14.95,
|
||||
"tests/integration/test_entity_icon.py": 15.57,
|
||||
"tests/integration/test_entity_missing_state.py": 13.91,
|
||||
"tests/integration/test_fan_turn_on_action.py": 13.85,
|
||||
"tests/integration/test_fnv1_hash_object_id.py": 10.12,
|
||||
"tests/integration/test_fnv1a_hash.py": 9.95,
|
||||
"tests/integration/test_gpio_expander_cache.py": 4.43,
|
||||
"tests/integration/test_host_logger_thread_safety.py": 12.54,
|
||||
"tests/integration/test_host_mode_basic.py": 12.66,
|
||||
"tests/integration/test_host_mode_batch_delay.py": 13.34,
|
||||
"tests/integration/test_host_mode_climate_basic_state.py": 15.19,
|
||||
"tests/integration/test_host_mode_climate_control.py": 15.35,
|
||||
"tests/integration/test_host_mode_empty_string_options.py": 13.04,
|
||||
"tests/integration/test_host_mode_entity_fields.py": 10.42,
|
||||
"tests/integration/test_host_mode_fan_preset.py": 11.7,
|
||||
"tests/integration/test_host_mode_many_entities.py": 16.79,
|
||||
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62,
|
||||
"tests/integration/test_host_mode_noise_encryption.py": 21.06,
|
||||
"tests/integration/test_host_mode_reconnect.py": 11.29,
|
||||
"tests/integration/test_host_mode_sensor.py": 12.25,
|
||||
"tests/integration/test_host_ota.py": 109.97,
|
||||
"tests/integration/test_host_preferences.py": 12.92,
|
||||
"tests/integration/test_host_preferences_suspend_resume.py": 12.3,
|
||||
"tests/integration/test_host_write_after_peer_close.py": 14.42,
|
||||
"tests/integration/test_improv_serial_uart.py": 16.29,
|
||||
"tests/integration/test_ir_rf_transmit_complete.py": 10.73,
|
||||
"tests/integration/test_large_message_batching.py": 13.94,
|
||||
"tests/integration/test_legacy_area.py": 13.34,
|
||||
"tests/integration/test_legacy_climate_compat.py": 13.19,
|
||||
"tests/integration/test_legacy_fan_compat.py": 9.31,
|
||||
"tests/integration/test_light_automations.py": 14.39,
|
||||
"tests/integration/test_light_binary_effect_off_phase.py": 14.75,
|
||||
"tests/integration/test_light_calls.py": 13.85,
|
||||
"tests/integration/test_light_constant_brightness.py": 12.22,
|
||||
"tests/integration/test_light_control_action.py": 15.46,
|
||||
"tests/integration/test_light_dim_relative_action.py": 15.3,
|
||||
"tests/integration/test_light_effect_zero_brightness.py": 16.75,
|
||||
"tests/integration/test_light_initial_state.py": 9.8,
|
||||
"tests/integration/test_light_repeat_effect.py": 22.27,
|
||||
"tests/integration/test_light_restore_state.py": 15.67,
|
||||
"tests/integration/test_light_resume_effect.py": 17.79,
|
||||
"tests/integration/test_light_toggle_action.py": 15.22,
|
||||
"tests/integration/test_light_transition_state_publish_interval.py": 44.96,
|
||||
"tests/integration/test_lock_automations.py": 12.55,
|
||||
"tests/integration/test_logger_buffered_recursion_guard.py": 11.78,
|
||||
"tests/integration/test_loop_disable_enable.py": 13.19,
|
||||
"tests/integration/test_loop_interval_decoupling.py": 15.14,
|
||||
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91,
|
||||
"tests/integration/test_lvgl_color_picker_render.py": 91.0,
|
||||
"tests/integration/test_lvgl_headless_render.py": 46.26,
|
||||
"tests/integration/test_micros_to_millis.py": 9.14,
|
||||
"tests/integration/test_multi_click_trigger.py": 13.62,
|
||||
"tests/integration/test_multi_device_preferences.py": 15.18,
|
||||
"tests/integration/test_noise_encryption_key_protection.py": 21.14,
|
||||
"tests/integration/test_object_id_api_verification.py": 9.54,
|
||||
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78,
|
||||
"tests/integration/test_object_id_no_friendly_name.py": 24.2,
|
||||
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07,
|
||||
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9,
|
||||
"tests/integration/test_online_image_bmp.py": 24.53,
|
||||
"tests/integration/test_oversized_payloads.py": 43.03,
|
||||
"tests/integration/test_preference_key_stability.py": 14.22,
|
||||
"tests/integration/test_runtime_stats.py": 15.85,
|
||||
"tests/integration/test_safe_mode_loop_runs.py": 8.89,
|
||||
"tests/integration/test_scheduler_background_wake.py": 17.82,
|
||||
"tests/integration/test_scheduler_blocking_warning.py": 30.27,
|
||||
"tests/integration/test_scheduler_bulk_cleanup.py": 8.78,
|
||||
"tests/integration/test_scheduler_defer_cancel.py": 12.9,
|
||||
"tests/integration/test_scheduler_defer_cancel_regular.py": 12.79,
|
||||
"tests/integration/test_scheduler_defer_fifo_simple.py": 13.14,
|
||||
"tests/integration/test_scheduler_defer_stress.py": 13.2,
|
||||
"tests/integration/test_scheduler_heap_stress.py": 9.15,
|
||||
"tests/integration/test_scheduler_internal_id_no_collision.py": 14.81,
|
||||
"tests/integration/test_scheduler_interval_reschedule.py": 13.07,
|
||||
"tests/integration/test_scheduler_interval_zero_coerced.py": 12.32,
|
||||
"tests/integration/test_scheduler_null_name.py": 3.81,
|
||||
"tests/integration/test_scheduler_numeric_id_test.py": 9.77,
|
||||
"tests/integration/test_scheduler_pool.py": 10.16,
|
||||
"tests/integration/test_scheduler_rapid_cancellation.py": 4.7,
|
||||
"tests/integration/test_scheduler_recursive_timeout.py": 13.72,
|
||||
"tests/integration/test_scheduler_removed_item_race.py": 12.73,
|
||||
"tests/integration/test_scheduler_self_keyed.py": 13.03,
|
||||
"tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88,
|
||||
"tests/integration/test_scheduler_string_test.py": 14.1,
|
||||
"tests/integration/test_script_array_params.py": 11.14,
|
||||
"tests/integration/test_script_delay_params.py": 13.84,
|
||||
"tests/integration/test_script_queued.py": 15.45,
|
||||
"tests/integration/test_script_queued_idle_loop.py": 13.15,
|
||||
"tests/integration/test_script_wait_on_boot.py": 12.64,
|
||||
"tests/integration/test_sdl_headless_screenshot.py": 15.19,
|
||||
"tests/integration/test_select_stringref_trigger.py": 13.51,
|
||||
"tests/integration/test_sensor_filters_delta.py": 13.6,
|
||||
"tests/integration/test_sensor_filters_ring_buffer.py": 9.22,
|
||||
"tests/integration/test_sensor_filters_sliding_window.py": 40.87,
|
||||
"tests/integration/test_sensor_filters_value_list.py": 15.43,
|
||||
"tests/integration/test_sensor_raw_state.py": 25.12,
|
||||
"tests/integration/test_sensor_timeout_filter.py": 9.64,
|
||||
"tests/integration/test_sensor_timeout_throttle_filter.py": 14.63,
|
||||
"tests/integration/test_set_internal_at_boot.py": 13.18,
|
||||
"tests/integration/test_snapshot_display.py": 16.01,
|
||||
"tests/integration/test_socket_ipv4_resolve.py": 11.3,
|
||||
"tests/integration/test_socket_tcp_client_link.py": 13.88,
|
||||
"tests/integration/test_socket_wake_gate_tcp.py": 14.28,
|
||||
"tests/integration/test_status_flags.py": 20.55,
|
||||
"tests/integration/test_status_momentary.py": 12.63,
|
||||
"tests/integration/test_strftime_to.py": 9.47,
|
||||
"tests/integration/test_syslog.py": 17.73,
|
||||
"tests/integration/test_tcp_uart_bridge.py": 16.27,
|
||||
"tests/integration/test_tcp_uart_server.py": 13.64,
|
||||
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42,
|
||||
"tests/integration/test_template_climate_basic.py": 14.42,
|
||||
"tests/integration/test_template_climate_custom_modes.py": 14.3,
|
||||
"tests/integration/test_template_climate_nonoptimistic.py": 10.11,
|
||||
"tests/integration/test_template_climate_on_control_ordering.py": 4.57,
|
||||
"tests/integration/test_template_climate_publish_all_fields.py": 12.56,
|
||||
"tests/integration/test_template_climate_sensor_push.py": 14.89,
|
||||
"tests/integration/test_template_climate_set_actions.py": 14.12,
|
||||
"tests/integration/test_template_climate_two_point_temperature.py": 9.08,
|
||||
"tests/integration/test_template_text_save.py": 13.96,
|
||||
"tests/integration/test_template_time.py": 12.73,
|
||||
"tests/integration/test_text_command.py": 13.09,
|
||||
"tests/integration/test_text_sensor_raw_state.py": 14.3,
|
||||
"tests/integration/test_uart_mock_ld2410.py": 33.11,
|
||||
"tests/integration/test_uart_mock_ld2412.py": 61.26,
|
||||
"tests/integration/test_uart_mock_ld2420.py": 23.39,
|
||||
"tests/integration/test_uart_mock_ld2450.py": 11.79,
|
||||
"tests/integration/test_uart_mock_modbus.py": 323.58,
|
||||
"tests/integration/test_uart_mock_ufm01.py": 71.03,
|
||||
"tests/integration/test_uart_tcp_bridge.py": 13.3,
|
||||
"tests/integration/test_udp.py": 7.04,
|
||||
"tests/integration/test_use_address_runtime.py": 12.4,
|
||||
"tests/integration/test_valve_control_action.py": 9.25,
|
||||
"tests/integration/test_varint_five_byte_device_id.py": 9.34,
|
||||
"tests/integration/test_wait_until_mid_loop_timing.py": 13.21,
|
||||
"tests/integration/test_wait_until_on_boot.py": 12.66,
|
||||
"tests/integration/test_wait_until_ordering.py": 9.06,
|
||||
"tests/integration/test_wait_until_reentrant_restart.py": 10.83,
|
||||
"tests/integration/test_wake_loop_forces_phase_b.py": 14.23,
|
||||
"tests/integration/test_water_heater_template.py": 21.96
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Golden-image test for the LVGL colour picker, drawn on a display with no screen.
|
||||
|
||||
Pickers are drawn with every slider, with a colour wheel, at the size taken from the font,
|
||||
and with only some of the sliders. The first two are then given a new colour by
|
||||
`lvgl.color_picker.update`, and the hue-only one is given white, and they are drawn again. Each picture is compared against a hash, as in
|
||||
test_lvgl_headless_render.py. If a change alters the output, the test fails, keeps the image it
|
||||
saw and prints its hash; update the hash once the image has been looked at and found to be
|
||||
correct.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from .artifact_utils import keep_artifact
|
||||
from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
WIDTH = 680
|
||||
HEIGHT = 480
|
||||
|
||||
# Every picker showing 0x3366CC.
|
||||
EXPECTED_INITIAL_SHA256 = (
|
||||
"5d93f4bd98fcbe418ef8d34572e3b0912e0421cfd87e61e8cc3c49778a0e0e47"
|
||||
)
|
||||
# The first two pickers showing 0xFF8800, and the hue-only one red rather than stuck at white,
|
||||
# after the update action.
|
||||
EXPECTED_UPDATED_SHA256 = (
|
||||
"8300a6d95f38e60ea3d175aa413e71b99de6ac7ad78c9adf5248bfb350cb35d3"
|
||||
)
|
||||
# The LVGL version the hashes were generated against; a version change can move them.
|
||||
EXPECTED_LVGL_VERSION = "9.6.0"
|
||||
|
||||
REDRAW_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _check(image: Bmp, capture: Path, expected: str, name: str) -> None:
|
||||
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
|
||||
digest = hashlib.sha256(image.pixels).hexdigest()
|
||||
if digest == expected:
|
||||
return
|
||||
kept = keep_artifact(f"lvgl_color_picker_{name}_actual.bmp", capture.read_bytes())
|
||||
|
||||
from esphome.components.lvgl import LVGL_VERSION
|
||||
|
||||
version_hint = ""
|
||||
if LVGL_VERSION != EXPECTED_LVGL_VERSION:
|
||||
version_hint = (
|
||||
f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> "
|
||||
f"{LVGL_VERSION}), which is the likely cause\n"
|
||||
)
|
||||
pytest.fail(
|
||||
f"{name} screen does not match the expected hash\n"
|
||||
f"{version_hint}"
|
||||
f" expected: {expected}\n"
|
||||
f" actual: {digest}\n"
|
||||
f"the image that was rendered has been kept at {kept}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lvgl_color_picker_render(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The colour picker draws as expected, before and after its colour is changed."""
|
||||
snapshot_dir = tmp_path / "snapshots"
|
||||
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
|
||||
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
_, services = await client.list_entities_services()
|
||||
take_screenshot = next(s for s in services if s.name == "take_screenshot")
|
||||
set_orange = next(s for s in services if s.name == "set_orange")
|
||||
|
||||
async def take(name: str) -> None:
|
||||
await client.execute_service(take_screenshot, {"name": name})
|
||||
|
||||
image, capture = await capture_when_drawn(take, snapshot_dir, prefix="initial")
|
||||
_check(image, capture, EXPECTED_INITIAL_SHA256, "initial")
|
||||
|
||||
await client.execute_service(set_orange, {})
|
||||
# The redraw follows the update on a later LVGL tick, so capture until the picture changes.
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + REDRAW_TIMEOUT
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
capture = snapshot_dir / f"updated-{attempt}.bmp"
|
||||
await take(capture.name)
|
||||
updated = await wait_for_bmp(capture)
|
||||
if updated.pixels != image.pixels:
|
||||
break
|
||||
if loop.time() >= deadline:
|
||||
pytest.fail(
|
||||
f"the screen did not change within {REDRAW_TIMEOUT}s of the update"
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
_check(updated, capture, EXPECTED_UPDATED_SHA256, "updated")
|
||||
@@ -30,7 +30,7 @@ EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd4865509
|
||||
# Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was
|
||||
# generated against. A version bump can shift anti-aliasing enough to change the hash even though
|
||||
# nothing is actually wrong -- if this test fails, check that first before regenerating the hash.
|
||||
EXPECTED_LVGL_VERSION = "9.5.0"
|
||||
EXPECTED_LVGL_VERSION = "9.6.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""A background scheduler insertion must interrupt the loop's current sleep.
|
||||
|
||||
Covers both set_timeout and defer: a zero-delay insert from another thread
|
||||
takes the separate defer queue on multi-threaded builds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from aioesphomeapi import ButtonInfo
|
||||
import pytest
|
||||
|
||||
from .state_utils import require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_background_wake(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
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()
|
||||
results: dict[int, asyncio.Future[tuple[int, int]]] = {
|
||||
100: loop.create_future(),
|
||||
0: loop.create_future(),
|
||||
}
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
match = re.search(
|
||||
r"SCHEDULER_WAKE_RESULT delay=(\d+) elapsed=(\d+) loop_delta=(-?\d+)",
|
||||
line,
|
||||
)
|
||||
if match is None:
|
||||
return
|
||||
result = results[int(match.group(1))]
|
||||
if not result.done():
|
||||
result.set_result((int(match.group(2)), int(match.group(3))))
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "scheduler-background-wake"
|
||||
entities, _ = await client.list_entities_services()
|
||||
for object_id, delay in (
|
||||
("start_scheduler_timeout", 100),
|
||||
("start_scheduler_defer", 0),
|
||||
):
|
||||
start_button = require_entity(
|
||||
entities,
|
||||
object_id,
|
||||
ButtonInfo,
|
||||
description=f"{object_id} button",
|
||||
)
|
||||
client.button_command(start_button.key)
|
||||
|
||||
try:
|
||||
elapsed, loop_delta = await asyncio.wait_for(
|
||||
results[delay], timeout=10.0
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"background insert with delay={delay} did not fire")
|
||||
|
||||
assert elapsed < 1000, (
|
||||
f"background insert with delay={delay} should interrupt the "
|
||||
f"five-second sleep; it fired after {elapsed}ms"
|
||||
)
|
||||
assert loop_delta == 0, (
|
||||
f"scheduler-only wake must not run component loops; observed "
|
||||
f"loop_delta={loop_delta} for delay={delay}"
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The merged timeout + throttle_with_priority filter publishes exactly what the two filter chain does."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
from aioesphomeapi import ButtonInfo, EntityState, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, build_key_to_entity_mapping
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_timeout_throttle_filter(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
values: dict[str, list[float]] = {"merged": [], "reference": []}
|
||||
done = {name: loop.create_future() for name in values}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if not isinstance(state, SensorState):
|
||||
return
|
||||
name = key_to_sensor.get(state.key)
|
||||
if name not in values:
|
||||
return
|
||||
values[name].append(math.nan if state.missing_state else state.state)
|
||||
# 99 is published last and republished once by the timeout
|
||||
if values[name].count(99.0) == 2 and not done[name].done():
|
||||
done[name].set_result(True)
|
||||
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
key_to_sensor = build_key_to_entity_mapping(
|
||||
entities, {"merged": "Merged", "reference": "Reference"}
|
||||
)
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
button = next(
|
||||
e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive"
|
||||
)
|
||||
client.button_command(button)
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*done.values()), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Timed out: {values}")
|
||||
|
||||
merged, reference = values["merged"], values["reference"]
|
||||
assert len(merged) == len(reference), (merged, reference)
|
||||
for got, want in zip(merged, reference, strict=True):
|
||||
assert (math.isnan(got) and math.isnan(want)) or got == want, (
|
||||
merged,
|
||||
reference,
|
||||
)
|
||||
assert any(math.isnan(v) for v in merged)
|
||||
@@ -35,6 +35,7 @@ async def test_socket_tcp_client_link(
|
||||
link_down = asyncio.Event()
|
||||
second_link_up = asyncio.Event()
|
||||
link_up_count = 0
|
||||
writers: list[asyncio.StreamWriter] = []
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
nonlocal link_up_count
|
||||
@@ -48,6 +49,7 @@ async def test_socket_tcp_client_link(
|
||||
async def handle(
|
||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
writers.append(writer)
|
||||
if not echo_done.is_set():
|
||||
writer.write(PAYLOAD)
|
||||
await writer.drain()
|
||||
@@ -98,5 +100,9 @@ async def test_socket_tcp_client_link(
|
||||
"Second session echo wrong; stale bytes from the first session?"
|
||||
)
|
||||
finally:
|
||||
# On Python 3.12 wait_closed() waits for every connection the handlers
|
||||
# left open; nothing else closes them once the device has exited.
|
||||
for writer in writers:
|
||||
writer.close()
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Integration test for status_momentary_warning/error without a name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import BinarySensorState, EntityState
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_momentary(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
warning_logged = loop.create_future()
|
||||
error_logged = loop.create_future()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
if (
|
||||
"set Warning flag: probe warning reason" in line
|
||||
and not warning_logged.done()
|
||||
):
|
||||
warning_logged.set_result(True)
|
||||
if "set Error flag: probe error reason" in line and not error_logged.done():
|
||||
error_logged.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, services = await client.list_entities_services()
|
||||
svc = {s.name: s for s in services}
|
||||
keys = {e.object_id: e.key for e in entities}
|
||||
warning_key = keys["probe_warning"]
|
||||
error_key = keys["probe_error"]
|
||||
|
||||
changes: asyncio.Queue[tuple[int, bool, float]] = asyncio.Queue()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, BinarySensorState):
|
||||
changes.put_nowait((state.key, state.state, loop.time()))
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
assert initial_state_helper.initial_states[warning_key].state is False
|
||||
assert initial_state_helper.initial_states[error_key].state is False
|
||||
|
||||
async def next_change(key: int) -> tuple[bool, float]:
|
||||
while True:
|
||||
k, value, when = await asyncio.wait_for(changes.get(), timeout=5)
|
||||
if k == key:
|
||||
return value, when
|
||||
|
||||
# The flag sets at once and clears after the length
|
||||
for service, key in (
|
||||
("momentary_warning", warning_key),
|
||||
("momentary_error", error_key),
|
||||
):
|
||||
start = loop.time()
|
||||
await client.execute_service(svc[service], {"length": 300})
|
||||
assert (await next_change(key))[0] is True
|
||||
value, cleared = await next_change(key)
|
||||
assert value is False
|
||||
assert cleared - start >= 0.25
|
||||
|
||||
# A second call restarts the timeout instead of adding a second one
|
||||
start = loop.time()
|
||||
await client.execute_service(svc["momentary_warning"], {"length": 1000})
|
||||
assert (await next_change(warning_key))[0] is True
|
||||
await asyncio.sleep(0.6)
|
||||
await client.execute_service(svc["momentary_warning"], {"length": 1000})
|
||||
value, cleared = await next_change(warning_key)
|
||||
assert value is False
|
||||
assert cleared - start >= 1.4
|
||||
|
||||
# A message replaces "unspecified" in the log line
|
||||
await client.execute_service(svc["momentary_warning_message"], {})
|
||||
await asyncio.wait_for(warning_logged, timeout=5)
|
||||
await client.execute_service(svc["momentary_error_message"], {})
|
||||
await asyncio.wait_for(error_logged, timeout=5)
|
||||
@@ -41,12 +41,14 @@ async def test_tcp_uart_bridge(
|
||||
service_byte_ok = asyncio.Event()
|
||||
reconnect_echo_ok = asyncio.Event()
|
||||
sessions = 0
|
||||
writers: list[asyncio.StreamWriter] = []
|
||||
|
||||
async def handle(
|
||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
nonlocal sessions
|
||||
sessions += 1
|
||||
writers.append(writer)
|
||||
if sessions == 1:
|
||||
writer.write(GREETING)
|
||||
await writer.drain()
|
||||
@@ -98,5 +100,9 @@ async def test_tcp_uart_bridge(
|
||||
reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again"
|
||||
)
|
||||
finally:
|
||||
# On Python 3.12 wait_closed() waits for every connection the handlers
|
||||
# left open; nothing else closes them once the device has exited.
|
||||
for writer in writers:
|
||||
writer.close()
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
@@ -20,19 +20,120 @@ test_uart_mock_ld2412_engineering_truncated (truncated engineering mode):
|
||||
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
|
||||
|
||||
The gate threshold tests (footer_in_payload, truncated_answer, requery_after_giveup) cover
|
||||
answers that hold the frame footer or are truncated, writes that fill the gates without a
|
||||
number from the module's values, and writes held back until the module reports them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
|
||||
from aioesphomeapi import ButtonInfo
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, SensorStateCollector, find_entity
|
||||
from .state_utils import (
|
||||
InitialStateHelper,
|
||||
SensorStateCollector,
|
||||
StateWaiter,
|
||||
find_entity,
|
||||
require_entity,
|
||||
)
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Length and command word of each frame as the uart mock logs it; the payload is logged on its own TX line
|
||||
MOTION_COMMAND = "10:00:03:00"
|
||||
STILL_COMMAND = "10:00:04:00"
|
||||
MOTION_QUERY = "02:00:13:00"
|
||||
STILL_QUERY = "02:00:14:00"
|
||||
CONFIG_MODE_LEFT = "02:00:FE:00"
|
||||
FACTORY_RESET = "02:00:A2:00"
|
||||
# Logged in place of the motion command when the write is held back or given up
|
||||
MOTION_HELD_BACK = "Command 03 held back"
|
||||
MOTION_GIVEN_UP = "Command 03 not sent, the module did not report"
|
||||
|
||||
THRESHOLD_NUMBERS = (
|
||||
"gate_0_move_threshold",
|
||||
"gate_0_still_threshold",
|
||||
"gate_5_move_threshold",
|
||||
"gate_5_still_threshold",
|
||||
)
|
||||
|
||||
|
||||
def _is_tx(line: str) -> bool:
|
||||
return "uart_mock" in line and "TX " in line
|
||||
|
||||
|
||||
def _threshold_numbers(entities: list[EntityInfo]) -> dict[str, NumberInfo]:
|
||||
"""Return the gate 0 and gate 5 threshold numbers by name."""
|
||||
return {
|
||||
name: require_entity(entities, name, NumberInfo) for name in THRESHOLD_NUMBERS
|
||||
}
|
||||
|
||||
|
||||
async def _wait_initial(
|
||||
client: APIClient,
|
||||
entities: list[EntityInfo],
|
||||
on_state: Callable[[EntityState], None] = lambda s: None,
|
||||
) -> InitialStateHelper:
|
||||
"""Subscribe to states and wait until every entity has sent its initial state."""
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
return initial_state_helper
|
||||
|
||||
|
||||
def _number_state_of(key: int) -> Callable[[EntityState], bool]:
|
||||
"""Return a predicate that matches a number state with a value for ``key``."""
|
||||
return lambda s: isinstance(s, NumberState) and not s.missing_state and s.key == key
|
||||
|
||||
|
||||
def _is_nan_of(key: int) -> Callable[[EntityState], bool]:
|
||||
"""Return a predicate that matches the number ``key`` being marked unknown."""
|
||||
return lambda s: _number_state_of(key)(s) and math.isnan(s.state)
|
||||
|
||||
|
||||
class _LogEvents:
|
||||
"""Record the TX lines and the log lines holding one of ``texts``, in the order they were logged."""
|
||||
|
||||
def __init__(self, *texts: str) -> None:
|
||||
self._texts = texts
|
||||
self.lines: list[str] = []
|
||||
self._awaited: dict[str, asyncio.Future[None]] = {}
|
||||
|
||||
def on_line(self, line: str) -> None:
|
||||
if _is_tx(line) or any(text in line for text in self._texts):
|
||||
self.lines.append(line)
|
||||
for text, future in self._awaited.items():
|
||||
if text in line and not future.done():
|
||||
future.set_result(None)
|
||||
|
||||
def next_line_with(self, text: str) -> asyncio.Future[None]:
|
||||
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
||||
self._awaited[text] = future
|
||||
return future
|
||||
|
||||
def index_after(self, start: int, text: str) -> int:
|
||||
return next(
|
||||
i for i, line in enumerate(self.lines) if i > start and text in line
|
||||
)
|
||||
|
||||
def count(self, text: str) -> int:
|
||||
return sum(text in line for line in self.lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2412(
|
||||
@@ -41,14 +142,6 @@ async def test_uart_mock_ld2412(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test LD2412 data parsing with happy path, garbage, overflow, and recovery."""
|
||||
# Replace external component path placeholder
|
||||
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 overflow warning in logs
|
||||
@@ -61,7 +154,7 @@ async def test_uart_mock_ld2412(
|
||||
if "Max command length exceeded" in line and not overflow_seen.done():
|
||||
overflow_seen.set_result(True)
|
||||
# Capture all TX log lines from uart_mock
|
||||
if "uart_mock" in line and "TX " in line:
|
||||
if _is_tx(line):
|
||||
tx_log_lines.append(line)
|
||||
|
||||
collector = SensorStateCollector(
|
||||
@@ -97,16 +190,7 @@ async def test_uart_mock_ld2412(
|
||||
entities, _ = await client.list_entities_services()
|
||||
collector.build_key_mapping(entities)
|
||||
|
||||
# Set up initial state helper
|
||||
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")
|
||||
await _wait_initial(client, entities, collector.on_state)
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
|
||||
@@ -176,12 +260,6 @@ async def test_uart_mock_ld2412_engineering(
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test LD2412 engineering mode with per-gate energy, light, and multi-byte distance."""
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
yaml_config = yaml_config.replace(
|
||||
"EXTERNAL_COMPONENT_PATH", external_components_path
|
||||
)
|
||||
|
||||
collector = SensorStateCollector(
|
||||
sensor_names=[
|
||||
@@ -220,15 +298,7 @@ async def test_uart_mock_ld2412_engineering(
|
||||
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")
|
||||
await _wait_initial(client, entities, collector.on_state)
|
||||
|
||||
# Start the UART mock scenario now that we're subscribed
|
||||
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
|
||||
@@ -288,12 +358,6 @@ async def test_uart_mock_ld2412_engineering_truncated(
|
||||
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()
|
||||
|
||||
@@ -341,15 +405,7 @@ async def test_uart_mock_ld2412_engineering_truncated(
|
||||
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")
|
||||
await _wait_initial(client, entities, collector.on_state)
|
||||
|
||||
start_btn = find_entity(entities, "start_scenario", ButtonInfo)
|
||||
assert start_btn is not None, "Start Scenario button not found"
|
||||
@@ -397,3 +453,268 @@ async def test_uart_mock_ld2412_engineering_truncated(
|
||||
f"truncated frame likely leaked stale buffer data. "
|
||||
f"All values: {collector.sensor_states['gate_0_move_energy']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2412_footer_in_payload(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test threshold answers that hold the frame footer 04 03 02 01, and the writes built from them."""
|
||||
# Gate 0 set to 77 (0x4D); the other 13 gates must match the motion answer byte for byte
|
||||
expected_motion = "4D:23:23:23:23:04:03:02:01:19:19:19:19:19"
|
||||
# Gate 5 set to 77, the rest matches the still answer
|
||||
expected_still = "00:23:23:23:23:4D:19:19:19:19:04:03:02:01"
|
||||
# Gate 0 set to 50 (0x32) after the reset
|
||||
expected_motion_after_reset = "32:23:23:23:23:04:03:02:01:19:19:19:19:19"
|
||||
events = _LogEvents(MOTION_HELD_BACK)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=events.on_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
initial_state_helper = await _wait_initial(client, entities)
|
||||
numbers = _threshold_numbers(entities)
|
||||
|
||||
# Gate 5 move is the first footer byte inside the motion answer
|
||||
for name, value in {
|
||||
"gate_0_move_threshold": 0.0,
|
||||
"gate_5_move_threshold": 4.0,
|
||||
"gate_0_still_threshold": 0.0,
|
||||
"gate_5_still_threshold": 25.0,
|
||||
}.items():
|
||||
state = initial_state_helper.initial_states[numbers[name].key]
|
||||
assert state.state == pytest.approx(value), name
|
||||
|
||||
motion_written = events.next_line_with(expected_motion)
|
||||
client.number_command(numbers["gate_0_move_threshold"].key, 77.0)
|
||||
try:
|
||||
await asyncio.wait_for(motion_written, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
"Timeout waiting for the motion threshold command.\n"
|
||||
f" expected motion payload: {expected_motion}"
|
||||
)
|
||||
|
||||
assert events.count(STILL_COMMAND) == 0, (
|
||||
"still threshold command was sent for a motion write"
|
||||
)
|
||||
|
||||
still_written = events.next_line_with(expected_still)
|
||||
client.number_command(numbers["gate_5_still_threshold"].key, 77.0)
|
||||
try:
|
||||
await asyncio.wait_for(still_written, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
"Timeout waiting for the still threshold command.\n"
|
||||
f" expected still payload: {expected_still}"
|
||||
)
|
||||
|
||||
assert events.count(MOTION_COMMAND) == 1
|
||||
assert events.count(STILL_COMMAND) == 1
|
||||
assert events.count(MOTION_HELD_BACK) == 0
|
||||
|
||||
# The reset discards the module's thresholds, so the next write is held back and re-queried
|
||||
reset_sent = events.next_line_with(FACTORY_RESET)
|
||||
client.button_command(require_entity(entities, "factory_reset", ButtonInfo).key)
|
||||
try:
|
||||
await asyncio.wait_for(reset_sent, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for the factory reset command")
|
||||
|
||||
held_back = events.next_line_with(MOTION_HELD_BACK)
|
||||
written_after_reset = events.next_line_with(expected_motion_after_reset)
|
||||
client.number_command(numbers["gate_0_move_threshold"].key, 50.0)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(held_back, written_after_reset), timeout=5.0
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
"Timeout waiting for the write after the factory reset.\n"
|
||||
f" expected motion payload: {expected_motion_after_reset}\n"
|
||||
f" logged: {events.lines}"
|
||||
)
|
||||
reset_at = events.index_after(-1, FACTORY_RESET)
|
||||
warning_at = events.index_after(reset_at, MOTION_HELD_BACK)
|
||||
query_at = events.index_after(warning_at, MOTION_QUERY)
|
||||
events.index_after(query_at, expected_motion_after_reset)
|
||||
|
||||
entities_after, _ = await client.list_entities_services()
|
||||
assert len(entities_after) == len(entities)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2412_truncated_answer(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that a threshold answer merged with the next frame is dropped and the parser recovers."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
dropped = loop.create_future()
|
||||
warnings: list[str] = []
|
||||
|
||||
def line_callback(line: str) -> None:
|
||||
if "[W][ld2412" in line:
|
||||
warnings.append(line)
|
||||
if "Dropping gate threshold answer" in line and not dropped.done():
|
||||
dropped.set_result(True)
|
||||
|
||||
# Gate bytes of the truncated answer
|
||||
merged_values = set(range(0x30, 0x3C))
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=line_callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
numbers = _threshold_numbers(entities)
|
||||
|
||||
states: dict[int, list[float]] = {}
|
||||
waiter = StateWaiter()
|
||||
gate_0_read = waiter.expect(
|
||||
_number_state_of(numbers["gate_0_move_threshold"].key),
|
||||
label="gate 0 move threshold",
|
||||
)
|
||||
gate_5_read = waiter.expect(
|
||||
_number_state_of(numbers["gate_5_move_threshold"].key),
|
||||
label="gate 5 move threshold",
|
||||
)
|
||||
|
||||
def record(state: EntityState) -> None:
|
||||
if isinstance(state, NumberState) and not state.missing_state:
|
||||
states.setdefault(state.key, []).append(state.state)
|
||||
waiter.on_state(state)
|
||||
|
||||
initial_state_helper = await _wait_initial(client, entities, record)
|
||||
|
||||
# The late answer may already have been parsed when the client connected
|
||||
for state in initial_state_helper.initial_states.values():
|
||||
record(state)
|
||||
|
||||
try:
|
||||
_, gate_0_state, gate_5_state = await asyncio.gather(
|
||||
asyncio.wait_for(dropped, timeout=5.0), gate_0_read, gate_5_read
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
"Timeout waiting for the threshold answers.\n"
|
||||
f" ld2412 warnings: {warnings}\n"
|
||||
f" states: {states}"
|
||||
)
|
||||
|
||||
assert gate_0_state.state == pytest.approx(10.0)
|
||||
assert gate_5_state.state == pytest.approx(15.0)
|
||||
|
||||
for number in numbers.values():
|
||||
for value in states.get(number.key, []):
|
||||
assert int(value) not in merged_values, (
|
||||
f"{number.object_id} took {value} from the merged frame"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ld2412_requery_after_giveup(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test a write held back until the module reports its thresholds: given up once, then sent."""
|
||||
# Gate 0 takes the module value 25 (0x19), gate 5 the user value 30 (0x1E)
|
||||
expected_motion = "19:0B:0C:0D:0E:1E:10:11:12:13:14:15:16:17"
|
||||
events = _LogEvents(MOTION_HELD_BACK, MOTION_GIVEN_UP)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=events.on_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
numbers = _threshold_numbers(entities)
|
||||
gate_0_key = numbers["gate_0_move_threshold"].key
|
||||
gate_5_key = numbers["gate_5_move_threshold"].key
|
||||
|
||||
gate_5_values: list[float] = []
|
||||
waiter = StateWaiter()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if _number_state_of(gate_5_key)(state):
|
||||
gate_5_values.append(state.state)
|
||||
waiter.on_state(state)
|
||||
|
||||
initial_state_helper = await _wait_initial(client, entities, on_state)
|
||||
|
||||
for key in (gate_0_key, gate_5_key):
|
||||
assert initial_state_helper.initial_states[key].missing_state
|
||||
|
||||
held_back = events.next_line_with(MOTION_HELD_BACK)
|
||||
config_mode_left = events.next_line_with(CONFIG_MODE_LEFT)
|
||||
given_up = events.next_line_with(MOTION_GIVEN_UP)
|
||||
unknown = [
|
||||
waiter.expect(_is_nan_of(key), label="NaN")
|
||||
for key in (gate_0_key, gate_5_key)
|
||||
]
|
||||
client.number_command(gate_5_key, 20.0)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(held_back, config_mode_left), timeout=5.0
|
||||
)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Timeout waiting for the write to be handled: {events.lines}")
|
||||
# The unanswered query times out after 2 s
|
||||
try:
|
||||
await asyncio.wait_for(given_up, timeout=3.0)
|
||||
await asyncio.gather(*unknown)
|
||||
except TimeoutError as err:
|
||||
pytest.fail(f"Timeout waiting for the first write to be given up: {err}")
|
||||
|
||||
warning_at = events.index_after(-1, MOTION_HELD_BACK)
|
||||
left_at = events.index_after(warning_at, CONFIG_MODE_LEFT)
|
||||
write_window = events.lines[warning_at:left_at]
|
||||
assert any(MOTION_QUERY in line for line in write_window), (
|
||||
"motion thresholds were not queried again after the command was held back"
|
||||
)
|
||||
assert not any(STILL_QUERY in line for line in write_window), (
|
||||
"still thresholds were queried for a motion write"
|
||||
)
|
||||
assert events.count(MOTION_COMMAND) == 0, (
|
||||
"motion threshold command was sent without a value for the gates that have no number"
|
||||
)
|
||||
|
||||
held_back_again = events.next_line_with(MOTION_HELD_BACK)
|
||||
motion_written = events.next_line_with(expected_motion)
|
||||
gate_0_refilled = waiter.expect(
|
||||
lambda s: _number_state_of(gate_0_key)(s) and not math.isnan(s.state),
|
||||
label="gate 0 move threshold from the module",
|
||||
)
|
||||
gate_5_values.clear()
|
||||
client.number_command(gate_5_key, 30.0)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(held_back_again, motion_written), timeout=5.0
|
||||
)
|
||||
gate_0_state = await gate_0_refilled
|
||||
except TimeoutError as err:
|
||||
pytest.fail(
|
||||
f"Timeout waiting for the second write: {err}\n"
|
||||
f" expected motion payload: {expected_motion}\n"
|
||||
f" logged: {events.lines}"
|
||||
)
|
||||
|
||||
warning_at = events.index_after(left_at, MOTION_HELD_BACK)
|
||||
query_at = events.index_after(warning_at, MOTION_QUERY)
|
||||
write_at = events.index_after(query_at, expected_motion)
|
||||
assert MOTION_COMMAND in events.lines[write_at - 1]
|
||||
|
||||
# One query each from setup, the given-up write and the second write
|
||||
assert events.count(MOTION_QUERY) == 3
|
||||
assert events.count(STILL_COMMAND) == 0, (
|
||||
"still threshold command was sent for a motion write"
|
||||
)
|
||||
|
||||
assert gate_0_state.state == pytest.approx(25.0)
|
||||
assert gate_5_values
|
||||
assert all(value == pytest.approx(30.0) for value in gate_5_values)
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Integration tests for the UFM-01 flow meter with a simulated meter on the mock UART.
|
||||
|
||||
All tests share uart_mock_ufm01.yaml, whose on_tx lambda answers like a UFM-01: it ACKs commands,
|
||||
answers sensor data and software version reads, and streams active frames each second after
|
||||
SET_ACTIVE_MODE. The "Silent" switch stops it answering, "Pause Stream" stops the stream while it still
|
||||
answers, and "Resume Stream" starts the stream again.
|
||||
|
||||
test_uart_mock_ufm01_startup:
|
||||
Reset, software version read and active mode go out in order, the active frames publish flow,
|
||||
temperature and accumulated flow, and the meter ID and software version publish once.
|
||||
test_uart_mock_ufm01_stale_recovery:
|
||||
A stopped stream marks flow and temperature unknown after 5 s and switches to passive polling,
|
||||
whose answers publish; a resumed stream switches back to the active stream and polling stops.
|
||||
test_uart_mock_ufm01_escalation:
|
||||
Eight unanswered passive polls send a reset and run the startup sequence again; values publish
|
||||
once the meter answers, and the diagnostics are not published again.
|
||||
test_uart_mock_ufm01_clear_action:
|
||||
The clear command is sent once the UART is idle while the automation continues at once; the
|
||||
meter's ACK, or its absence, is only logged, and a second request joins the pending one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import math
|
||||
import re
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
SensorState,
|
||||
SwitchInfo,
|
||||
TextSensorState,
|
||||
UserService,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, StateWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16
|
||||
RESET_DEVICE = "FE:FE:11:5D:CB:28:16"
|
||||
SET_ACTIVE_MODE = "FE:FE:11:5C:00:5C:16"
|
||||
SET_PASSIVE_MODE = "FE:FE:11:5C:01:5D:16"
|
||||
READ_SENSOR_DATA = "FE:FE:11:5B:0F:6A:16"
|
||||
GET_SOFTWARE_VERSION = "FE:FE:11:5E:62:C0:16"
|
||||
CLEAR_ACCUMULATED_FLOW = "FE:FE:11:5A:FD:57:16"
|
||||
|
||||
# Values the fixture's frames encode
|
||||
ACTIVE_FLOW = 1.2345
|
||||
ACTIVE_TEMPERATURE = 23.5
|
||||
ACTIVE_ACCUMULATED = 12.345
|
||||
PASSIVE_FLOW = 0.567
|
||||
PASSIVE_TEMPERATURE = 19.25
|
||||
PASSIVE_ACCUMULATED = 25.678
|
||||
METER_ID = "2307140001"
|
||||
SOFTWARE_VERSION = "23380315"
|
||||
|
||||
ACTIVE_STARTED = "UFM-01 active stream started"
|
||||
ACTIVE_RESUMED = "UFM-01 active stream resumed"
|
||||
STALE = "Active stream stale, switching to passive polling"
|
||||
ESCALATED = "Passive poll failed repeatedly, re-initializing UFM-01"
|
||||
CLEAR_ACKED = "Clear accumulated flow acknowledged"
|
||||
CLEAR_NOT_ACKED = "Clear accumulated flow not acknowledged"
|
||||
|
||||
# Startup wait, reset ACK, post-reset wait, then the first frame within the 1 s stream interval
|
||||
STARTUP_TIMEOUT = 8.0
|
||||
ACTIVE_STALE_S = 5.0
|
||||
|
||||
ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class _Log:
|
||||
"""Record TX frames and ufm01 log lines with their arrival time, and wait for lines."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[float, str]] = []
|
||||
self._awaited: list[tuple[str, asyncio.Future[float]]] = []
|
||||
|
||||
def on_line(self, line: str) -> None:
|
||||
line = ANSI_ESCAPE.sub("", line)
|
||||
if "uart_mock" in line and "TX " in line and "bytes: " in line:
|
||||
text = line.split("bytes: ", 1)[1].strip()
|
||||
elif "[ufm01" in line:
|
||||
text = line
|
||||
else:
|
||||
return
|
||||
now = asyncio.get_running_loop().time()
|
||||
self.events.append((now, text))
|
||||
for wanted, future in self._awaited:
|
||||
if wanted in text and not future.done():
|
||||
future.set_result(now)
|
||||
|
||||
def next(self, text: str) -> asyncio.Future[float]:
|
||||
"""Return a future for the time of the next event containing ``text``."""
|
||||
future: asyncio.Future[float] = asyncio.get_running_loop().create_future()
|
||||
self._awaited.append((text, future))
|
||||
return future
|
||||
|
||||
@property
|
||||
def tx(self) -> list[str]:
|
||||
return [text for _, text in self.events if text.startswith("FE:")]
|
||||
|
||||
def index(self, text: str, start: int = 0) -> int:
|
||||
return next(
|
||||
i
|
||||
for i, (_, event) in enumerate(self.events)
|
||||
if i >= start and text in event
|
||||
)
|
||||
|
||||
def tx_between(self, start: int, end: int) -> list[str]:
|
||||
return [text for _, text in self.events[start:end] if text.startswith("FE:")]
|
||||
|
||||
|
||||
class _Meter:
|
||||
"""Entities and state history of the running fixture."""
|
||||
|
||||
def __init__(
|
||||
self, client: APIClient, entities: list[EntityInfo], services: list[UserService]
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.services = services
|
||||
self.by_id = {entity.object_id: entity for entity in entities}
|
||||
self.waiter = StateWaiter()
|
||||
self.states: dict[str, list[float | str]] = {name: [] for name in self.by_id}
|
||||
self._names = {entity.key: entity.object_id for entity in entities}
|
||||
self.helper = InitialStateHelper(entities)
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
if (
|
||||
isinstance(state, (SensorState, TextSensorState))
|
||||
and not state.missing_state
|
||||
):
|
||||
self.states[self._names[state.key]].append(state.state)
|
||||
self.waiter.on_state(state)
|
||||
|
||||
def expect(
|
||||
self, name: str, check: Callable[[float | str], bool], timeout: float
|
||||
) -> Awaitable[EntityState]:
|
||||
key = self.by_id[name].key
|
||||
return self.waiter.expect(
|
||||
lambda s: s.key == key and not s.missing_state and check(s.state),
|
||||
timeout=timeout,
|
||||
label=name,
|
||||
)
|
||||
|
||||
def press(self, name: str) -> None:
|
||||
entity = self.by_id[name]
|
||||
assert isinstance(entity, ButtonInfo)
|
||||
self.client.button_command(entity.key)
|
||||
|
||||
def set_silent(self, silent: bool) -> None:
|
||||
entity = self.by_id["silent"]
|
||||
assert isinstance(entity, SwitchInfo)
|
||||
self.client.switch_command(entity.key, silent)
|
||||
|
||||
def all_values(self, name: str) -> list[float | str]:
|
||||
"""Return every value ``name`` held, including one already set at connect time."""
|
||||
initial = self.helper.initial_states[self.by_id[name].key]
|
||||
return ([] if initial.missing_state else [initial.state]) + self.states[name]
|
||||
|
||||
|
||||
async def _connect(client: APIClient) -> _Meter:
|
||||
entities, services = await client.list_entities_services()
|
||||
meter = _Meter(client, entities, services)
|
||||
client.subscribe_states(meter.helper.on_state_wrapper(meter.on_state))
|
||||
try:
|
||||
await meter.helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
return meter
|
||||
|
||||
|
||||
def _is_nan(value: float | str) -> bool:
|
||||
return isinstance(value, float) and math.isnan(value)
|
||||
|
||||
|
||||
def _approx(expected: float) -> Callable[[float | str], bool]:
|
||||
return lambda value: value == pytest.approx(expected, abs=1e-4)
|
||||
|
||||
|
||||
def _active_values(meter: _Meter, timeout: float) -> list[Awaitable[EntityState]]:
|
||||
"""Arm waits for the next flow, temperature and accumulated flow from an active frame."""
|
||||
return [
|
||||
meter.expect("flow", _approx(ACTIVE_FLOW), timeout),
|
||||
meter.expect("temperature", _approx(ACTIVE_TEMPERATURE), timeout),
|
||||
meter.expect("accumulated_flow", _approx(ACTIVE_ACCUMULATED), timeout),
|
||||
]
|
||||
|
||||
|
||||
async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None:
|
||||
try:
|
||||
await asyncio.gather(*waits)
|
||||
except TimeoutError as err:
|
||||
pytest.fail(f"{err}; states: {meter.states}")
|
||||
|
||||
|
||||
async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float:
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Timed out; log: {log.events}")
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_ufm01")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ufm01_startup(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Startup reaches the active stream, publishes the decoded values and the diagnostics once."""
|
||||
log = _Log()
|
||||
started = log.next(ACTIVE_STARTED)
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=log.on_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
meter = await _connect(client)
|
||||
values = _active_values(meter, STARTUP_TIMEOUT)
|
||||
await _wait_for(log, started, STARTUP_TIMEOUT)
|
||||
await _gather(meter, values)
|
||||
# Two more stream frames
|
||||
for _ in range(2):
|
||||
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
|
||||
|
||||
assert log.tx == [RESET_DEVICE, GET_SOFTWARE_VERSION, SET_ACTIVE_MODE]
|
||||
assert meter.all_values("meter_id") == [METER_ID]
|
||||
assert meter.all_values("software_version") == [SOFTWARE_VERSION]
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_ufm01")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ufm01_stale_recovery(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A stopped stream falls back to passive polling and a resumed stream ends it."""
|
||||
log = _Log()
|
||||
started = log.next(ACTIVE_STARTED)
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=log.on_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
meter = await _connect(client)
|
||||
await _wait_for(log, started, STARTUP_TIMEOUT)
|
||||
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
|
||||
|
||||
stale = log.next(STALE)
|
||||
flow_unknown = meter.expect("flow", _is_nan, ACTIVE_STALE_S + 2)
|
||||
temperature_unknown = meter.expect("temperature", _is_nan, ACTIVE_STALE_S + 2)
|
||||
polled = [
|
||||
meter.expect("flow", _approx(PASSIVE_FLOW), ACTIVE_STALE_S + 4),
|
||||
meter.expect(
|
||||
"temperature", _approx(PASSIVE_TEMPERATURE), ACTIVE_STALE_S + 4
|
||||
),
|
||||
meter.expect(
|
||||
"accumulated_flow", _approx(PASSIVE_ACCUMULATED), ACTIVE_STALE_S + 4
|
||||
),
|
||||
]
|
||||
meter.press("pause_stream")
|
||||
paused_at = asyncio.get_running_loop().time()
|
||||
stale_at = await _wait_for(log, stale, ACTIVE_STALE_S + 2)
|
||||
# The last frame came up to 1 s before the pause
|
||||
assert stale_at - paused_at >= ACTIVE_STALE_S - 1.5
|
||||
await _gather(meter, [flow_unknown, temperature_unknown])
|
||||
await _gather(meter, polled)
|
||||
|
||||
stale_index = log.index(STALE)
|
||||
assert log.tx_between(stale_index, len(log.events))[:2] == [
|
||||
SET_PASSIVE_MODE,
|
||||
READ_SENSOR_DATA,
|
||||
]
|
||||
# Unknown first, then the passive answers
|
||||
assert _is_nan(meter.states["flow"][-2])
|
||||
assert _is_nan(meter.states["temperature"][-2])
|
||||
|
||||
resumed = log.next(ACTIVE_RESUMED)
|
||||
meter.press("resume_stream")
|
||||
values = _active_values(meter, 3.0)
|
||||
await _wait_for(log, resumed, 3.0)
|
||||
await _gather(meter, values)
|
||||
# Two more stream frames without any poll in between
|
||||
for _ in range(2):
|
||||
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
|
||||
assert READ_SENSOR_DATA not in log.tx_between(
|
||||
log.index(ACTIVE_RESUMED), len(log.events)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_ufm01")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ufm01_escalation(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Eight unanswered passive polls reset the meter and rerun the startup sequence."""
|
||||
log = _Log()
|
||||
started = log.next(ACTIVE_STARTED)
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=log.on_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
meter = await _connect(client)
|
||||
await _wait_for(log, started, STARTUP_TIMEOUT)
|
||||
await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0)
|
||||
|
||||
escalated = log.next(ESCALATED)
|
||||
meter.set_silent(True)
|
||||
# Stale after 5 s, then eight polls that each time out after 1 s
|
||||
flow_count = len(meter.states["flow"])
|
||||
await _wait_for(log, escalated, ACTIVE_STALE_S + 8 + 4)
|
||||
|
||||
restarted = log.next(ACTIVE_STARTED)
|
||||
values = _active_values(meter, STARTUP_TIMEOUT)
|
||||
meter.set_silent(False)
|
||||
await _wait_for(log, restarted, STARTUP_TIMEOUT)
|
||||
await _gather(meter, values)
|
||||
|
||||
stale_index = log.index(STALE)
|
||||
escalated_index = log.index(ESCALATED, stale_index)
|
||||
assert (
|
||||
log.tx_between(stale_index, escalated_index)
|
||||
== [SET_PASSIVE_MODE] + [READ_SENSOR_DATA] * 8
|
||||
)
|
||||
# A reset (resent once if the meter was still silent), then active mode; the software
|
||||
# version is already known, so it is not read again
|
||||
after = log.tx_between(
|
||||
escalated_index, log.index(ACTIVE_STARTED, escalated_index)
|
||||
)
|
||||
assert after in (
|
||||
[RESET_DEVICE, SET_ACTIVE_MODE],
|
||||
[RESET_DEVICE, RESET_DEVICE, SET_ACTIVE_MODE],
|
||||
), after
|
||||
# Unknown when the stream went stale and again on the re-init, nothing in between;
|
||||
# a frame already in flight when the meter went silent may come first
|
||||
flow = meter.states["flow"][flow_count:]
|
||||
first_unknown = next(i for i, value in enumerate(flow) if _is_nan(value))
|
||||
assert _is_nan(flow[first_unknown + 1]), flow
|
||||
assert flow[first_unknown + 2] == pytest.approx(ACTIVE_FLOW, abs=1e-4), flow
|
||||
|
||||
assert meter.all_values("meter_id") == [METER_ID]
|
||||
assert meter.all_values("software_version") == [SOFTWARE_VERSION]
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_ufm01")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_ufm01_clear_action(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The clear command is sent once the UART is idle; the automation continues at once."""
|
||||
log = _Log()
|
||||
started = log.next(ACTIVE_STARTED)
|
||||
acked = log.next(CLEAR_ACKED)
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=log.on_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
meter = await _connect(client)
|
||||
# A second entity listing would re-send the initial states and duplicate the first completion
|
||||
(clear,) = meter.services
|
||||
# Normally still inside the startup wait; a slow runner may already be streaming
|
||||
queued_during_startup = not started.done()
|
||||
|
||||
completed = meter.expect("clear_completions", _approx(1), STARTUP_TIMEOUT)
|
||||
zeroed = meter.expect("accumulated_flow", _approx(0.0), STARTUP_TIMEOUT + 2)
|
||||
await client.execute_service(clear, {})
|
||||
await _wait_for(log, acked, STARTUP_TIMEOUT)
|
||||
await _gather(meter, [completed, zeroed])
|
||||
|
||||
# Queued during startup, sent only once the active stream started
|
||||
if queued_during_startup:
|
||||
assert CLEAR_ACCUMULATED_FLOW not in log.tx_between(
|
||||
0, log.index(ACTIVE_STARTED)
|
||||
)
|
||||
assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 1
|
||||
assert log.index(CLEAR_ACCUMULATED_FLOW) < log.index(CLEAR_ACKED)
|
||||
|
||||
# Two requests in a row share one command; without an answer it is reported after the 500 ms ACK timeout
|
||||
sent = log.next(CLEAR_ACCUMULATED_FLOW)
|
||||
not_acked = log.next(CLEAR_NOT_ACKED)
|
||||
completed = meter.expect("clear_completions", _approx(3), 3.0)
|
||||
meter.set_silent(True)
|
||||
await asyncio.sleep(0.2)
|
||||
await client.execute_service(clear, {})
|
||||
await client.execute_service(clear, {})
|
||||
sent_at = await _wait_for(log, sent, 2.0)
|
||||
not_acked_at = await _wait_for(log, not_acked, 2.0)
|
||||
await _gather(meter, [completed])
|
||||
assert not_acked_at - sent_at >= 0.4
|
||||
assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 2
|
||||
assert meter.states["clear_completions"] == [1.0, 2.0, 3.0]
|
||||
@@ -84,13 +84,16 @@ async def test_uart_tcp_bridge(
|
||||
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", server_port)
|
||||
await wait_log_count("Client connected", 1)
|
||||
# The accept log precedes the stale discard in the same loop pass, so
|
||||
# prove the device is past it with a client to UART round trip before
|
||||
# writing the bytes the client must see.
|
||||
writer.write(b"down1")
|
||||
await writer.drain()
|
||||
assert await read_uart(5) == b"down1"
|
||||
os.write(controller_fd, b"live!")
|
||||
assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live!", (
|
||||
"First bytes to the client were not the live payload"
|
||||
)
|
||||
writer.write(b"down1")
|
||||
await writer.drain()
|
||||
assert await read_uart(5) == b"down1"
|
||||
|
||||
# Drop the client; bytes while no client is connected are discarded
|
||||
# when the next one is accepted.
|
||||
@@ -101,13 +104,13 @@ async def test_uart_tcp_bridge(
|
||||
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", server_port)
|
||||
await wait_log_count("Client connected", 2)
|
||||
writer.write(b"down2")
|
||||
await writer.drain()
|
||||
assert await read_uart(5) == b"down2"
|
||||
os.write(controller_fd, b"live2")
|
||||
assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live2", (
|
||||
"Second client received stale bytes from the gap"
|
||||
)
|
||||
writer.write(b"down2")
|
||||
await writer.drain()
|
||||
assert await read_uart(5) == b"down2"
|
||||
writer.close()
|
||||
|
||||
# A peer outside the allow list is rejected and closed.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Integration test for the UDP component's deferred multicast join."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
MULTICAST_GROUP = "239.1.2.3"
|
||||
|
||||
|
||||
def _get_free_udp_port() -> int:
|
||||
"""Get a free UDP port by binding to port 0 and releasing."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_udp_multicast_join(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The group is joined from the poll after setup, once, and then receives."""
|
||||
log_lines: list[str] = []
|
||||
joined_event = asyncio.Event()
|
||||
receive_event = asyncio.Event()
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
log_lines.append(line)
|
||||
if f"Joined multicast {MULTICAST_GROUP}" in line:
|
||||
joined_event.set()
|
||||
if "Received UDP:" in line:
|
||||
receive_event.set()
|
||||
|
||||
listen_port = _get_free_udp_port()
|
||||
config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port))
|
||||
|
||||
async with (
|
||||
run_compiled(config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "udp-multicast-test"
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(joined_event.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Multicast group was not joined. Log: {log_lines[-20:]}")
|
||||
|
||||
# The poll cancels itself after the join, so a second poll period must not join again
|
||||
await asyncio.sleep(2.5)
|
||||
join_lines = [line for line in log_lines if "Joined multicast" in line]
|
||||
assert len(join_lines) == 1, f"Expected one join, got: {join_lines}"
|
||||
assert not any("IP_ADD_MEMBERSHIP" in line for line in log_lines), (
|
||||
f"Join reported a failure. Log: {log_lines[-20:]}"
|
||||
)
|
||||
|
||||
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
send_sock.sendto(b"MULTICAST_PAYLOAD", (MULTICAST_GROUP, listen_port))
|
||||
finally:
|
||||
send_sock.close()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(receive_event.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Multicast datagram was not received. Log: {log_lines[-20:]}")
|
||||
Reference in New Issue
Block a user