1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-29 22:24:26 +00:00

Merge remote-tracking branch 'upstream/dev' into ci_impact_analysis

This commit is contained in:
J. Nick Koston
2025-10-17 13:19:47 -10:00
214 changed files with 4367 additions and 839 deletions

View File

@@ -69,7 +69,7 @@ def run_schema_validation(config: ConfigType) -> None:
{
"id": "display_id",
"model": "custom",
"dimensions": {"width": 320, "height": 240},
"dimensions": {"width": 260, "height": 260},
"draw_rounding": 13,
"init_sequence": [[0xA0, 0x01]],
},
@@ -336,7 +336,7 @@ def test_native_generation(
main_cpp = generate_main(component_fixture_path("native.yaml"))
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, display::DISPLAY_ROTATION_0_DEGREES, 1>()"
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, display::DISPLAY_ROTATION_0_DEGREES, 1, 1>()"
in main_cpp
)
assert "set_init_sequence({240, 1, 8, 242" in main_cpp

View File

@@ -1,5 +1,5 @@
substitutions:
irq0_pin: GPIO13
irq0_pin: GPIO0
irq1_pin: GPIO15
reset_pin: GPIO16

View File

@@ -4,10 +4,13 @@ sensor:
irq_pin: ${irq_pin}
voltage:
name: ADE7953 Voltage
id: ade7953_i2c_voltage
current_a:
name: ADE7953 Current A
id: ade7953_i2c_current_a
current_b:
name: ADE7953 Current B
id: ade7953_i2c_current_b
power_factor_a:
name: ADE7953 Power Factor A
power_factor_b:

View File

@@ -4,13 +4,13 @@ sensor:
irq_pin: ${irq_pin}
voltage:
name: ADE7953 Voltage
id: ade7953_voltage
id: ade7953_spi_voltage
current_a:
name: ADE7953 Current A
id: ade7953_current_a
id: ade7953_spi_current_a
current_b:
name: ADE7953 Current B
id: ade7953_current_b
id: ade7953_spi_current_b
power_factor_a:
name: ADE7953 Power Factor A
power_factor_b:

View File

@@ -1,13 +1,16 @@
as3935_i2c:
id: as3935_i2c_id
i2c_id: i2c_bus
irq_pin: ${irq_pin}
binary_sensor:
- platform: as3935
as3935_id: as3935_i2c_id
name: Storm Alert
sensor:
- platform: as3935
as3935_id: as3935_i2c_id
lightning_energy:
name: Lightning Energy
distance:

View File

@@ -1,13 +1,16 @@
as3935_spi:
id: as3935_spi_id
cs_pin: ${cs_pin}
irq_pin: ${irq_pin}
binary_sensor:
- platform: as3935
as3935_id: as3935_spi_id
name: Storm Alert
sensor:
- platform: as3935
as3935_id: as3935_spi_id
lightning_energy:
name: Lightning Energy
distance:

View File

@@ -1,7 +1,7 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
id: ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: 19
pages:
@@ -13,6 +13,6 @@ touchscreen:
- platform: axs15231
i2c_id: i2c_bus
id: axs15231_touchscreen
display: ssd1306_display
display: ssd1306_i2c_display
interrupt_pin: 20
reset_pin: 18

View File

@@ -0,0 +1,6 @@
sensor:
- platform: bh1900nux
i2c_id: i2c_bus
name: Temperature Living Room
address: 0x48
update_interval: 30s

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,19 @@
<<: !include common.yaml
esp32_ble_tracker:
max_connections: 9
bluetooth_proxy:
active: true
connection_slots: 9
esp32_hosted:
active_high: true
variant: ESP32C6
reset_pin: GPIO54
cmd_pin: GPIO19
clk_pin: GPIO18
d0_pin: GPIO14
d1_pin: GPIO15
d2_pin: GPIO16
d3_pin: GPIO17

View File

@@ -3,12 +3,12 @@ sensor:
i2c_id: i2c_bus
address: 0x76
temperature:
id: bme280_temperature
id: bme280_i2c_temperature
name: BME280 Temperature
humidity:
id: bme280_humidity
id: bme280_i2c_humidity
name: BME280 Humidity
pressure:
id: bme280_pressure
id: bme280_i2c_pressure
name: BME280 Pressure
update_interval: 15s

View File

@@ -2,12 +2,12 @@ sensor:
- platform: bme280_spi
cs_pin: ${cs_pin}
temperature:
id: bme280_temperature
id: bme280_spi_temperature
name: BME280 Temperature
humidity:
id: bme280_humidity
id: bme280_spi_humidity
name: BME280 Humidity
pressure:
id: bme280_pressure
id: bme280_spi_pressure
name: BME280 Pressure
update_interval: 15s

View File

@@ -3,10 +3,10 @@ sensor:
i2c_id: i2c_bus
address: 0x77
temperature:
id: bmp280_temperature
id: bmp280_i2c_temperature
name: Outside Temperature
pressure:
name: Outside Pressure
id: bmp280_pressure
id: bmp280_i2c_pressure
iir_filter: 16x
update_interval: 15s

View File

@@ -2,10 +2,10 @@ sensor:
- platform: bmp280_spi
cs_pin: ${cs_pin}
temperature:
id: bmp280_temperature
id: bmp280_spi_temperature
name: Outside Temperature
pressure:
name: Outside Pressure
id: bmp280_pressure
id: bmp280_spi_pressure
iir_filter: 16x
update_interval: 15s

View File

@@ -3,8 +3,10 @@ sensor:
i2c_id: i2c_bus
address: 0x77
temperature:
id: bmp3xx_i2c_temperature
name: BMP Temperature
oversampling: 16x
pressure:
id: bmp3xx_i2c_pressure
name: BMP Pressure
iir_filter: 2X

View File

@@ -2,8 +2,10 @@ sensor:
- platform: bmp3xx_spi
cs_pin: ${cs_pin}
temperature:
id: bmp3xx_spi_temperature
name: BMP Temperature
oversampling: 16x
pressure:
id: bmp3xx_spi_pressure
name: BMP Pressure
iir_filter: 2X

View File

@@ -1,4 +1,4 @@
packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml
i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +1,4 @@
packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml
i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -7,8 +7,8 @@ display:
id: ili9xxx_display
model: GC9A01A
invert_colors: True
cs_pin: 10
dc_pin: 6
cs_pin: 11
dc_pin: 7
pages:
- id: page1
lambda: |-

View File

@@ -4,6 +4,7 @@ packages:
display:
- platform: ili9xxx
spi_id: spi_bus
id: ili9xxx_display
model: GC9A01A
invert_colors: True
@@ -16,5 +17,6 @@ display:
touchscreen:
- platform: chsc6x
i2c_id: i2c_bus
display: ili9xxx_display
interrupt_pin: 20

View File

@@ -1,7 +1,7 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
id: ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
@@ -15,7 +15,7 @@ touchscreen:
id: ektf2232_touchscreen
interrupt_pin: ${interrupt_pin}
reset_pin: ${touch_reset_pin}
display: ssd1306_display
display: ssd1306_i2c_display
on_touch:
- logger.log:
format: Touch at (%d, %d)

View File

@@ -3,8 +3,11 @@ sensor:
i2c_id: i2c_bus
address: 0x53
eco2:
id: ens160_i2c_eco2
name: "ENS160 eCO2"
tvoc:
id: ens160_i2c_tvoc
name: "ENS160 Total Volatile Organic Compounds"
aqi:
id: ens160_i2c_aqi
name: "ENS160 Air Quality Index"

View File

@@ -2,8 +2,11 @@ sensor:
- platform: ens160_spi
cs_pin: ${cs_pin}
eco2:
id: ens160_spi_eco2
name: "ENS160 eCO2"
tvoc:
id: ens160_spi_tvoc
name: "ENS160 Total Volatile Organic Compounds"
aqi:
id: ens160_spi_aqi
name: "ENS160 Air Quality Index"

View File

@@ -5,6 +5,7 @@ esp32:
advanced:
enable_lwip_mdns_queries: true
enable_lwip_bridge_interface: true
disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization
wifi:
ssid: MySSID

View File

@@ -4,6 +4,7 @@ esp32:
type: esp-idf
advanced:
execute_from_psram: true
disable_libc_locks_in_iram: true # Test default RAM optimization enabled
psram:
mode: octal

View File

@@ -1,4 +1,4 @@
packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml
i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +1,4 @@
packages:
camera: !include ../../test_build_components/common/camera/esp32-idf.yaml
i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -49,6 +49,7 @@ font:
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}

View File

@@ -1,5 +1,5 @@
substitutions:
interrupt_pin: GPIO12
interrupt_pin: GPIO0
reset_pin: GPIO16
packages:

View File

@@ -11,6 +11,7 @@ graph:
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
model: SSD1306_128X64
reset_pin: ${reset_pin}

View File

@@ -1,6 +1,6 @@
display:
- platform: ssd1306_i2c
id: ssd1306_display
id: ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${reset_pin}
pages:
@@ -36,7 +36,7 @@ switch:
graphical_display_menu:
id: test_graphical_display_menu
display: ssd1306_display
display: ssd1306_i2c_display
font: roboto
active: false
mode: rotary

View File

@@ -1,7 +1,7 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
id: ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
@@ -13,7 +13,7 @@ touchscreen:
- platform: gt911
i2c_id: i2c_bus
id: gt911_touchscreen
display: ssd1306_display
display: ssd1306_i2c_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}

View File

@@ -1,5 +1,5 @@
substitutions:
clk_pin: GPIO4
dout_pin: GPIO5
clk_pin: GPIO0
dout_pin: GPIO2
<<: !include common.yaml

View File

@@ -7,9 +7,22 @@ sensor:
max_current: 40 A
adc_range: 1
temperature_coefficient: 50
shunt_voltage: "INA2xx Shunt Voltage"
bus_voltage: "INA2xx Bus Voltage"
current: "INA2xx Current"
power: "INA2xx Power"
energy: "INA2xx Energy"
charge: "INA2xx Charge"
reset_on_boot: true
shunt_voltage:
id: ina2xx_i2c_shunt_voltage
name: "INA2xx Shunt Voltage"
bus_voltage:
id: ina2xx_i2c_bus_voltage
name: "INA2xx Bus Voltage"
current:
id: ina2xx_i2c_current
name: "INA2xx Current"
power:
id: ina2xx_i2c_power
name: "INA2xx Power"
energy:
id: ina2xx_i2c_energy
name: "INA2xx Energy"
charge:
id: ina2xx_i2c_charge
name: "INA2xx Charge"

View File

@@ -6,9 +6,21 @@ sensor:
max_current: 40 A
adc_range: 1
temperature_coefficient: 50
shunt_voltage: "INA2xx Shunt Voltage"
bus_voltage: "INA2xx Bus Voltage"
current: "INA2xx Current"
power: "INA2xx Power"
energy: "INA2xx Energy"
charge: "INA2xx Charge"
shunt_voltage:
id: ina2xx_spi_shunt_voltage
name: "INA2xx Shunt Voltage"
bus_voltage:
id: ina2xx_spi_bus_voltage
name: "INA2xx Bus Voltage"
current:
id: ina2xx_spi_current
name: "INA2xx Current"
power:
id: ina2xx_spi_power
name: "INA2xx Power"
energy:
id: ina2xx_spi_energy
name: "INA2xx Energy"
charge:
id: ina2xx_spi_charge
name: "INA2xx Charge"

View File

@@ -1,7 +1,7 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
id: ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${reset_pin}
pages:
@@ -14,7 +14,7 @@ touchscreen:
i2c_id: i2c_bus
id: lilygo_touchscreen
interrupt_pin: ${interrupt_pin}
display: ssd1306_display
display: ssd1306_i2c_display
on_touch:
- logger.log:
format: Touch at (%d, %d)

View File

@@ -1,3 +1,4 @@
output:
- platform: mcp4725
id: mcp4725_dac_output
i2c_id: i2c_bus

View File

@@ -1,3 +1,4 @@
output:
- platform: mcp47a1
id: output_mcp47a1
i2c_id: i2c_bus

View File

@@ -10,7 +10,7 @@ display:
invert_colors: true
show_test_card: true
spi_mode: mode0
draw_rounding: 8
draw_rounding: 4
use_axis_flips: true
init_sequence:
- [0xd0, 1, 2, 3]

View File

@@ -1,7 +1,7 @@
substitutions:
dc_pin: GPIO14
cs_pin: GPIO13
enable_pin: GPIO16
enable_pin: GPIO17
reset_pin: GPIO20
packages:

View File

@@ -0,0 +1,7 @@
nrf52:
dfu:
reset_pin:
number: 14
inverted: true
mode:
output: true

View File

@@ -1,9 +1,9 @@
pn532_i2c:
i2c_id: i2c_bus
id: pn532_nfcc
id: pn532_nfcc_i2c
binary_sensor:
- platform: pn532
pn532_id: pn532_nfcc
pn532_id: pn532_nfcc_i2c
name: PN532 NFC Tag
uid: 74-10-37-94

View File

@@ -1,9 +1,9 @@
pn532_spi:
id: pn532_nfcc
id: pn532_nfcc_spi
cs_pin: ${cs_pin}
binary_sensor:
- platform: pn532
pn532_id: pn532_nfcc
pn532_id: pn532_nfcc_spi
name: PN532 NFC Tag
uid: 74-10-37-94

View File

@@ -1,23 +1,23 @@
esphome:
on_boot:
then:
- tag.set_clean_mode: nfcc_pn7160
- tag.set_format_mode: nfcc_pn7160
- tag.set_read_mode: nfcc_pn7160
- tag.set_clean_mode: nfcc_pn7160_i2c
- tag.set_format_mode: nfcc_pn7160_i2c
- tag.set_read_mode: nfcc_pn7160_i2c
- tag.set_write_message:
message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false
- tag.set_write_mode: nfcc_pn7160
- tag.set_write_mode: nfcc_pn7160_i2c
- tag.set_emulation_message:
message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false
- tag.emulation_off: nfcc_pn7160
- tag.emulation_on: nfcc_pn7160
- tag.polling_off: nfcc_pn7160
- tag.polling_on: nfcc_pn7160
- tag.emulation_off: nfcc_pn7160_i2c
- tag.emulation_on: nfcc_pn7160_i2c
- tag.polling_off: nfcc_pn7160_i2c
- tag.polling_on: nfcc_pn7160_i2c
pn7150_i2c:
id: nfcc_pn7160
id: nfcc_pn7160_i2c
i2c_id: i2c_bus
irq_pin: ${irq_pin}
ven_pin: ${ven_pin}

View File

@@ -1,23 +1,23 @@
esphome:
on_boot:
then:
- tag.set_clean_mode: nfcc_pn7160
- tag.set_format_mode: nfcc_pn7160
- tag.set_read_mode: nfcc_pn7160
- tag.set_clean_mode: nfcc_pn7160_spi
- tag.set_format_mode: nfcc_pn7160_spi
- tag.set_read_mode: nfcc_pn7160_spi
- tag.set_write_message:
message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false
- tag.set_write_mode: nfcc_pn7160
- tag.set_write_mode: nfcc_pn7160_spi
- tag.set_emulation_message:
message: https://www.home-assistant.io/tag/pulse
include_android_app_record: false
- tag.emulation_off: nfcc_pn7160
- tag.emulation_on: nfcc_pn7160
- tag.polling_off: nfcc_pn7160
- tag.polling_on: nfcc_pn7160
- tag.emulation_off: nfcc_pn7160_spi
- tag.emulation_on: nfcc_pn7160_spi
- tag.polling_off: nfcc_pn7160_spi
- tag.polling_on: nfcc_pn7160_spi
pn7160_spi:
id: nfcc_pn7160
id: nfcc_pn7160_spi
cs_pin: ${cs_pin}
irq_pin: ${irq_pin}
ven_pin: ${ven_pin}

View File

@@ -1,5 +1,5 @@
rc522_i2c:
- id: rc522_nfcc
- id: rc522_nfcc_i2c
i2c_id: i2c_bus
update_interval: 1s
on_tag:
@@ -8,6 +8,6 @@ rc522_i2c:
binary_sensor:
- platform: rc522
rc522_id: rc522_nfcc
rc522_id: rc522_nfcc_i2c
name: RC522 NFC Tag
uid: 74-10-37-94

View File

@@ -1,9 +1,9 @@
rc522_spi:
id: rc522_nfcc
id: rc522_nfcc_spi
cs_pin: ${cs_pin}
binary_sensor:
- platform: rc522
rc522_id: rc522_nfcc
name: PN532 NFC Tag
rc522_id: rc522_nfcc_spi
name: RC522 NFC Tag
uid: 74-10-37-94

View File

@@ -1,7 +1,7 @@
substitutions:
tx_pin: GPIO0
rx_pin: GPIO2
flow_control_pin: GPIO4
flow_control_pin: GPIO15
packages:
modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml

View File

@@ -2,8 +2,8 @@ packages:
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
substitutions:
clock_pin: GPIO5
data_pin: GPIO4
clock_pin: GPIO15
data_pin: GPIO16
latch_pin1: GPIO2
oe_pin1: GPIO0
latch_pin2: GPIO3

View File

@@ -4,7 +4,7 @@ display:
model: SSD1306_128X64
reset_pin: ${reset_pin}
address: 0x3C
id: display1
id: ssd1306_i2c_display
contrast: 60%
pages:
- id: ssd1306_i2c_page1

View File

@@ -1,5 +1,6 @@
display:
- platform: ssd1306_spi
id: ssd1306_spi_display
model: SSD1306 128x64
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}

View File

@@ -4,7 +4,7 @@ display:
model: SSD1327_128x128
reset_pin: ${reset_pin}
address: 0x3C
id: display1
id: ssd1327_i2c_display
pages:
- id: ssd1327_i2c_page1
lambda: |-

View File

@@ -1,5 +1,6 @@
display:
- platform: ssd1327_spi
id: ssd1327_spi_display
model: SSD1327 128x128
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}

View File

@@ -3,7 +3,7 @@ display:
i2c_id: i2c_bus
reset_pin: ${reset_pin}
address: 0x3C
id: display1
id: st7567_i2c_display
pages:
- id: st7567_i2c_page1
lambda: |-

View File

@@ -1,5 +1,6 @@
display:
- platform: st7567_spi
id: st7567_spi_display
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}
reset_pin: ${reset_pin}

View File

@@ -6,7 +6,8 @@ udp:
addresses: ["239.0.60.53"]
time:
platform: host
- platform: host
id: host_time
syslog:
port: 514

View File

@@ -0,0 +1,13 @@
remote_transmitter:
pin: ${tx_pin}
carrier_duty_percent: 50%
remote_receiver:
id: rcvr
pin: ${rx_pin}
climate:
- platform: toshiba
name: "RAS-2819T Climate"
model: RAS-2819T
receiver_id: rcvr

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO5
rx_pin: GPIO4
<<: !include common_ras2819t.yaml

View File

@@ -1,7 +1,7 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: ssd1306_display
id: ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${disp_reset_pin}
pages:
@@ -13,7 +13,7 @@ touchscreen:
- platform: tt21100
i2c_id: i2c_bus
id: tt21100_touchscreen
display: ssd1306_display
display: ssd1306_i2c_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}

View File

@@ -1,4 +1,5 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,20 +1,20 @@
wk2132_spi:
- id: wk2132_spi_id
- id: wk2132_spi_bridge
cs_pin: ${cs_pin}
crystal: 11059200
data_rate: 1MHz
uart:
- id: wk2132_spi_id0
- id: wk2132_spi_uart0
channel: 0
baud_rate: 115200
stop_bits: 1
parity: none
- id: wk2132_spi_id1
- id: wk2132_spi_uart1
channel: 1
baud_rate: 9600
# Ensures a sensor doesn't break validation
sensor:
- platform: a02yyuw
uart_id: wk2132_spi_id1
uart_id: wk2132_spi_uart1
id: distance_sensor

View File

@@ -3,5 +3,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +1,5 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -3,5 +3,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +1,5 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,28 +1,28 @@
wk2204_spi:
- id: wk2204_spi_id
- id: wk2204_spi_bridge
cs_pin: ${cs_pin}
crystal: 11059200
data_rate: 1MHz
uart:
- id: wk2204_spi_id0
- id: wk2204_spi_uart0
channel: 0
baud_rate: 115200
stop_bits: 1
parity: none
- id: wk2204_spi_id1
- id: wk2204_spi_uart1
channel: 1
baud_rate: 921600
- id: wk2204_spi_id2
- id: wk2204_spi_uart2
channel: 2
baud_rate: 115200
stop_bits: 1
parity: none
- id: wk2204_spi_id3
- id: wk2204_spi_uart3
channel: 3
baud_rate: 9600
# Ensures a sensor doesn't break validation
sensor:
- platform: a02yyuw
uart_id: wk2204_spi_id3
uart_id: wk2204_spi_uart3
id: distance_sensor

View File

@@ -3,5 +3,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -1,4 +1,5 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -4,5 +4,6 @@ substitutions:
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -3,5 +3,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -6,5 +6,6 @@ substitutions:
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -2,11 +2,13 @@
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
import tempfile
import pytest
from esphome.core import CORE
from esphome.dashboard.settings import DashboardSettings
@@ -159,3 +161,63 @@ def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> No
result = dashboard_settings.rel_path("123", "456.789")
expected = dashboard_settings.config_dir / "123" / "456.789"
assert result == expected
def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None:
"""Test that CORE.config_path.parent resolves to config_dir after parse_args.
This is a regression test for issue #11280 where binary download failed
when using packages with secrets after the Path migration in 2025.10.0.
The issue was that after switching from os.path to Path:
- Before: os.path.dirname("/config/.") → "/config"
- After: Path("/config/.").parent → Path("/") (normalized first!)
The fix uses a sentinel file so .parent returns the correct directory:
- Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config")
"""
# Create test directory structure with secrets and packages
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create secrets.yaml with obviously fake test values
secrets_file = config_dir / "secrets.yaml"
secrets_file.write_text(
"wifi_ssid: TEST-DUMMY-SSID\n"
"wifi_password: not-a-real-password-just-for-testing\n"
)
# Create package file that uses secrets
package_file = config_dir / "common.yaml"
package_file.write_text(
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
)
# Create main device config that includes the package
device_config = config_dir / "test-device.yaml"
device_config.write_text(
"esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n"
)
# Set up dashboard settings with our test config directory
settings = DashboardSettings()
args = Namespace(
configuration=str(config_dir),
password=None,
username=None,
ha_addon=False,
verbose=False,
)
settings.parse_args(args)
# Verify that CORE.config_path.parent correctly points to the config directory
# This is critical for secret resolution in yaml_util.py which does:
# main_config_dir = CORE.config_path.parent
# main_secret_yml = main_config_dir / "secrets.yaml"
assert CORE.config_path.parent == config_dir.resolve()
assert (CORE.config_path.parent / "secrets.yaml").exists()
assert (CORE.config_path.parent / "common.yaml").exists()
# Verify that CORE.config_path itself uses the sentinel file
assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml"
assert not CORE.config_path.exists() # Sentinel file doesn't actually exist

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from argparse import Namespace
import asyncio
from collections.abc import Generator
from contextlib import asynccontextmanager
@@ -17,6 +18,8 @@ from tornado.ioloop import IOLoop
from tornado.testing import bind_unused_port
from tornado.websocket import WebSocketClientConnection, websocket_connect
from esphome import yaml_util
from esphome.core import CORE
from esphome.dashboard import web_server
from esphome.dashboard.const import DashboardEvent
from esphome.dashboard.core import DASHBOARD
@@ -1302,3 +1305,71 @@ async def test_dashboard_subscriber_refresh_event(
# Give it a moment to clean up
await asyncio.sleep(0.01)
@pytest.mark.asyncio
async def test_dashboard_yaml_loading_with_packages_and_secrets(
tmp_path: Path,
) -> None:
"""Test dashboard YAML loading with packages referencing secrets.
This is a regression test for issue #11280 where binary download failed
when using packages with secrets after the Path migration in 2025.10.0.
This test verifies that CORE.config_path initialization in the dashboard
allows yaml_util.load_yaml() to correctly resolve secrets from packages.
"""
# Create test directory structure with secrets and packages
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create secrets.yaml with obviously fake test values
secrets_file = config_dir / "secrets.yaml"
secrets_file.write_text(
"wifi_ssid: TEST-DUMMY-SSID\n"
"wifi_password: not-a-real-password-just-for-testing\n"
)
# Create package file that uses secrets
package_file = config_dir / "common.yaml"
package_file.write_text(
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
)
# Create main device config that includes the package
device_config = config_dir / "test-download-secrets.yaml"
device_config.write_text(
"esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n"
"packages:\n common: !include common.yaml\n"
)
# Initialize DASHBOARD settings with our test config directory
# This is what sets CORE.config_path - the critical code path for the bug
args = Namespace(
configuration=str(config_dir),
password=None,
username=None,
ha_addon=False,
verbose=False,
)
DASHBOARD.settings.parse_args(args)
# With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml"
# so CORE.config_path.parent would be config_dir
# Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir
# so CORE.config_path.parent would be tmp_path (the parent of config_dir)
# The fix ensures CORE.config_path.parent points to config_dir
assert CORE.config_path.parent == config_dir.resolve(), (
f"CORE.config_path.parent should point to config_dir. "
f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. "
f"CORE.config_path is {CORE.config_path}"
)
# Now load the YAML with packages that reference secrets
# This is where the bug would manifest - yaml_util.load_yaml would fail
# to find secrets.yaml because CORE.config_path.parent pointed to the wrong place
config = yaml_util.load_yaml(device_config)
# If we get here, secret resolution worked!
assert "esphome" in config
assert config["esphome"]["name"] == "test-download-secrets"

View File

@@ -87,9 +87,11 @@ def test_main_all_tests_should_run(
mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True
# Mock list-components.py output
# Mock list-components.py output (now returns JSON with --changed-with-deps)
mock_result = Mock()
mock_result.stdout = "wifi\napi\nsensor\n"
mock_result.stdout = json.dumps(
{"directly_changed": ["wifi", "api"], "all_changed": ["wifi", "api", "sensor"]}
)
mock_subprocess_run.return_value = mock_result
# Run main function with mocked argv
@@ -138,7 +140,7 @@ def test_main_no_tests_should_run(
# Mock empty list-components.py output
mock_result = Mock()
mock_result.stdout = ""
mock_result.stdout = json.dumps({"directly_changed": [], "all_changed": []})
mock_subprocess_run.return_value = mock_result
# Run main function with mocked argv
@@ -207,7 +209,9 @@ def test_main_with_branch_argument(
# Mock list-components.py output
mock_result = Mock()
mock_result.stdout = "mqtt\n"
mock_result.stdout = json.dumps(
{"directly_changed": ["mqtt"], "all_changed": ["mqtt"]}
)
mock_subprocess_run.return_value = mock_result
with patch("sys.argv", ["script.py", "-b", "main"]):
@@ -222,7 +226,7 @@ def test_main_with_branch_argument(
# Check that list-components.py was called with branch
mock_subprocess_run.assert_called_once()
call_args = mock_subprocess_run.call_args[0][0]
assert "--changed" in call_args
assert "--changed-with-deps" in call_args
assert "-b" in call_args
assert "main" in call_args
@@ -449,7 +453,12 @@ def test_main_filters_components_without_tests(
# Mock list-components.py output with 3 components
# wifi: has tests, sensor: has tests, airthings_ble: no tests
mock_result = Mock()
mock_result.stdout = "wifi\nsensor\nairthings_ble\n"
mock_result.stdout = json.dumps(
{
"directly_changed": ["wifi", "sensor"],
"all_changed": ["wifi", "sensor", "airthings_ble"],
}
)
mock_subprocess_run.return_value = mock_result
# Create test directory structure
@@ -474,6 +483,8 @@ def test_main_filters_components_without_tests(
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch("sys.argv", ["determine-jobs.py"]),
):
# Clear the cache since we're mocking root_path
determine_jobs._component_has_tests.cache_clear()
determine_jobs.main()
# Check output

View File

@@ -6,6 +6,9 @@ esp32:
board: lolin_c3_mini
framework:
type: esp-idf
# Use custom partition table with larger app partition (3MB)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger:
level: VERY_VERBOSE

View File

@@ -3,9 +3,13 @@ esphome:
friendly_name: $component_name
esp32:
board: nodemcu-32s
# Use board with 8MB flash for testing large component groups
board: esp32-pico-devkitm-2
framework:
type: esp-idf
# Use custom partition table with larger app partitions (3MB each)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger:
level: VERY_VERBOSE

View File

@@ -0,0 +1,15 @@
esphome:
name: componenttestnrf52
friendly_name: $component_name
nrf52:
board: xiao_ble
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file

View File

@@ -1,3 +1,10 @@
# I2C bus for camera sensor
i2c:
- id: i2c_camera_bus
sda: 25
scl: 23
frequency: 400kHz
esp32_camera:
name: ESP32 Camera
data_pins:
@@ -15,9 +22,7 @@ esp32_camera:
external_clock:
pin: 27
frequency: 20MHz
i2c_pins:
sda: 25
scl: 23
i2c_id: i2c_camera_bus
reset_pin: 15
power_down_pin: 1
resolution: 640x480

View File

@@ -0,0 +1,11 @@
# Common configuration for 2-channel UART bridge/expander chips
# Used by components like wk2132 that create 2 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,11 @@
# Common configuration for 2-channel UART bridge/expander chips
# Used by components like wk2132 that create 2 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,11 @@
# Common configuration for 4-channel UART bridge/expander chips
# Used by components like wk2168, wk2204, wk2212 that create 4 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1, uart_id_2, uart_id_3
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,11 @@
# Common configuration for 4-channel UART bridge/expander chips
# Used by components like wk2168, wk2204, wk2212 that create 4 UART channels
# Defines standardized UART IDs: uart_id_0, uart_id_1, uart_id_2, uart_id_3
substitutions:
# These will be overridden by component-specific values
uart_bridge_address: "0x70"
# Note: The actual UART instances are created by the bridge component
# This package just ensures all bridge components use the same ID naming convention
# so they can be grouped together without conflicts

View File

@@ -0,0 +1,10 @@
# ESP-IDF Partition Table for ESPHome Component Testing
# Single app partition to maximize space for large component group testing
# Fits in 4MB flash
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, , 0x2000,
phy_init, data, phy, , 0x1000,
factory, app, factory, 0x10000, 0x300000,
nvs_key, data, nvs_keys,, 0x1000,
coredump, data, coredump,, 0xEB000,
1 # ESP-IDF Partition Table for ESPHome Component Testing
2 # Single app partition to maximize space for large component group testing
3 # Fits in 4MB flash
4 # Name, Type, SubType, Offset, Size, Flags
5 nvs, data, nvs, 0x9000, 0x4000,
6 otadata, data, ota, , 0x2000,
7 phy_init, data, phy, , 0x1000,
8 factory, app, factory, 0x10000, 0x300000,
9 nvs_key, data, nvs_keys,, 0x1000,
10 coredump, data, coredump,, 0xEB000,

View File

@@ -96,6 +96,13 @@ def mock_run_git_command() -> Generator[Mock, None, None]:
yield mock
@pytest.fixture
def mock_subprocess_run() -> Generator[Mock, None, None]:
"""Mock subprocess.run for testing."""
with patch("subprocess.run") as mock:
yield mock
@pytest.fixture
def mock_get_idedata() -> Generator[Mock, None, None]:
"""Mock get_idedata for platformio_api."""

View File

@@ -287,7 +287,7 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None:
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# Should not send any auth-related data
auth_calls = [
@@ -317,7 +317,7 @@ def test_perform_ota_with_compression(mock_socket: Mock) -> None:
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# Verify compressed content was sent
# Get the binary size that was sent (4 bytes after features)
@@ -347,7 +347,7 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None:
with pytest.raises(
espota2.OTAError, match="ESP requests password, but no password given"
):
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@pytest.mark.usefixtures("mock_time")
@@ -413,7 +413,7 @@ def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None:
with pytest.raises(
espota2.OTAError, match="ESP requests password, but no password given"
):
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None:
@@ -450,7 +450,7 @@ def test_perform_ota_unsupported_version(mock_socket: Mock) -> None:
mock_socket.recv.side_effect = responses
with pytest.raises(espota2.OTAError, match="Device uses unsupported OTA version"):
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@pytest.mark.usefixtures("mock_time")
@@ -471,7 +471,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N
mock_socket.recv.side_effect = recv_responses
with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"):
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip")
@@ -706,7 +706,7 @@ def test_perform_ota_version_differences(
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# For v1.0, verify that we only get the expected number of recv calls
# v1.0 doesn't have chunk acknowledgments, so fewer recv calls
@@ -732,7 +732,7 @@ def test_perform_ota_version_differences(
]
mock_socket.recv.side_effect = recv_responses_v2
espota2.perform_ota(mock_socket, "", mock_file, "test.bin")
espota2.perform_ota(mock_socket, None, mock_file, "test.bin")
# For v2.0, verify more recv calls due to chunk acknowledgments
assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK)

View File

@@ -1,13 +1,204 @@
"""Tests for git.py module."""
from datetime import datetime, timedelta
import hashlib
import os
from pathlib import Path
from typing import Any
from unittest.mock import Mock
import pytest
from esphome import git
from esphome.core import CORE, TimePeriodSeconds
from esphome.git import GitCommandError
def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path:
"""Helper to compute the expected repo directory path using git module's logic."""
key = f"{url}@{ref}"
return git._compute_destination_path(key, domain)
def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None:
"""Helper to set up a git repo directory structure with an old timestamp.
Args:
repo_dir: The repository directory path to create.
days_old: Number of days old to make the FETCH_HEAD file (default: 2).
"""
# Create repo directory
repo_dir.mkdir(parents=True)
git_dir = repo_dir / ".git"
git_dir.mkdir()
# Create FETCH_HEAD file with old timestamp
fetch_head = git_dir / "FETCH_HEAD"
fetch_head.write_text("test")
old_time = datetime.now() - timedelta(days=days_old)
fetch_head.touch()
os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp()))
def _get_git_command_type(cmd: list[str]) -> str | None:
"""Helper to determine the type of git command from a command list.
Args:
cmd: The git command list (e.g., ["git", "rev-parse", "HEAD"]).
Returns:
The command type ("rev-parse", "stash", "fetch", "reset", "clone") or None.
"""
# Git commands are always in format ["git", "command", ...], so check index 1
if len(cmd) > 1:
return cmd[1]
return None
def test_run_git_command_success(tmp_path: Path) -> None:
"""Test that run_git_command returns output on success."""
# Create a simple git repo to test with
repo_dir = tmp_path / "test_repo"
repo_dir.mkdir()
# Initialize a git repo
result = git.run_git_command(["git", "init"], str(repo_dir))
assert "Initialized empty Git repository" in result or result == ""
# Verify we can run a command and get output
result = git.run_git_command(["git", "status", "--porcelain"], str(repo_dir))
# Empty repo should have empty status
assert isinstance(result, str)
def test_run_git_command_with_git_dir_isolation(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""Test that git_dir parameter properly isolates git operations."""
repo_dir = tmp_path / "test_repo"
repo_dir.mkdir()
git_dir = repo_dir / ".git"
git_dir.mkdir()
# Configure mock to return success
mock_subprocess_run.return_value = Mock(
returncode=0,
stdout=b"test output",
stderr=b"",
)
result = git.run_git_command(
["git", "rev-parse", "HEAD"],
git_dir=repo_dir,
)
# Verify subprocess.run was called
assert mock_subprocess_run.called
call_args = mock_subprocess_run.call_args
# Verify environment was set
env = call_args[1]["env"]
assert "GIT_DIR" in env
assert "GIT_WORK_TREE" in env
assert env["GIT_DIR"] == str(repo_dir / ".git")
assert env["GIT_WORK_TREE"] == str(repo_dir)
assert result == "test output"
def test_run_git_command_raises_git_not_installed_error(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""Test that FileNotFoundError is converted to GitNotInstalledError."""
from esphome.git import GitNotInstalledError
repo_dir = tmp_path / "test_repo"
# Configure mock to raise FileNotFoundError
mock_subprocess_run.side_effect = FileNotFoundError("git not found")
with pytest.raises(GitNotInstalledError, match="git is not installed"):
git.run_git_command(["git", "status"], git_dir=repo_dir)
def test_run_git_command_raises_git_command_error_on_failure(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""Test that failed git commands raise GitCommandError."""
repo_dir = tmp_path / "test_repo"
# Configure mock to return non-zero exit code
mock_subprocess_run.return_value = Mock(
returncode=1,
stdout=b"",
stderr=b"fatal: not a git repository",
)
with pytest.raises(GitCommandError, match="not a git repository"):
git.run_git_command(["git", "status"], git_dir=repo_dir)
def test_run_git_command_strips_fatal_prefix(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""Test that 'fatal: ' prefix is stripped from error messages."""
repo_dir = tmp_path / "test_repo"
# Configure mock to return error with "fatal: " prefix
mock_subprocess_run.return_value = Mock(
returncode=128,
stdout=b"",
stderr=b"fatal: repository not found\n",
)
with pytest.raises(GitCommandError) as exc_info:
git.run_git_command(["git", "clone", "invalid-url"], git_dir=repo_dir)
# Error message should NOT include "fatal: " prefix
assert "fatal:" not in str(exc_info.value)
assert "repository not found" in str(exc_info.value)
def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None:
"""Test that run_git_command works without git_dir (clone case)."""
# Configure mock to return success
mock_subprocess_run.return_value = Mock(
returncode=0,
stdout=b"Cloning into 'test_repo'...",
stderr=b"",
)
result = git.run_git_command(["git", "clone", "https://github.com/test/repo"])
# Verify subprocess.run was called
assert mock_subprocess_run.called
call_args = mock_subprocess_run.call_args
# Verify environment does NOT have GIT_DIR or GIT_WORK_TREE set
# (it should use the default environment or None)
env = call_args[1].get("env")
if env is not None:
assert "GIT_DIR" not in env
assert "GIT_WORK_TREE" not in env
# Verify cwd is None (default)
assert call_args[1].get("cwd") is None
assert result == "Cloning into 'test_repo'..."
def test_run_git_command_without_git_dir_raises_error(
mock_subprocess_run: Mock,
) -> None:
"""Test that run_git_command without git_dir can still raise errors."""
# Configure mock to return error
mock_subprocess_run.return_value = Mock(
returncode=128,
stdout=b"",
stderr=b"fatal: repository not found\n",
)
with pytest.raises(GitCommandError, match="repository not found"):
git.run_git_command(["git", "clone", "https://invalid.url/repo.git"])
def test_clone_or_update_with_never_refresh(
@@ -17,16 +208,10 @@ def test_clone_or_update_with_never_refresh(
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
# Compute the expected repo directory path
url = "https://github.com/test/repo"
ref = None
key = f"{url}@{ref}"
domain = "test"
# Compute hash-based directory name (matching _compute_destination_path logic)
h = hashlib.new("sha256")
h.update(key.encode())
repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8]
repo_dir = _compute_repo_dir(url, ref, domain)
# Create the git repo directory structure
repo_dir.mkdir(parents=True)
@@ -58,16 +243,10 @@ def test_clone_or_update_with_refresh_updates_old_repo(
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
# Compute the expected repo directory path
url = "https://github.com/test/repo"
ref = None
key = f"{url}@{ref}"
domain = "test"
# Compute hash-based directory name (matching _compute_destination_path logic)
h = hashlib.new("sha256")
h.update(key.encode())
repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8]
repo_dir = _compute_repo_dir(url, ref, domain)
# Create the git repo directory structure
repo_dir.mkdir(parents=True)
@@ -112,16 +291,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo(
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
# Compute the expected repo directory path
url = "https://github.com/test/repo"
ref = None
key = f"{url}@{ref}"
domain = "test"
# Compute hash-based directory name (matching _compute_destination_path logic)
h = hashlib.new("sha256")
h.update(key.encode())
repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8]
repo_dir = _compute_repo_dir(url, ref, domain)
# Create the git repo directory structure
repo_dir.mkdir(parents=True)
@@ -158,16 +331,10 @@ def test_clone_or_update_clones_missing_repo(
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
# Compute the expected repo directory path
url = "https://github.com/test/repo"
ref = None
key = f"{url}@{ref}"
domain = "test"
# Compute hash-based directory name (matching _compute_destination_path logic)
h = hashlib.new("sha256")
h.update(key.encode())
repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8]
repo_dir = _compute_repo_dir(url, ref, domain)
# Create base directory but NOT the repo itself
base_dir = tmp_path / ".esphome" / domain
@@ -200,16 +367,10 @@ def test_clone_or_update_with_none_refresh_always_updates(
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
# Compute the expected repo directory path
url = "https://github.com/test/repo"
ref = None
key = f"{url}@{ref}"
domain = "test"
# Compute hash-based directory name (matching _compute_destination_path logic)
h = hashlib.new("sha256")
h.update(key.encode())
repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8]
repo_dir = _compute_repo_dir(url, ref, domain)
# Create the git repo directory structure
repo_dir.mkdir(parents=True)
@@ -244,3 +405,269 @@ def test_clone_or_update_with_none_refresh_always_updates(
if len(call[0]) > 0 and "fetch" in call[0][0]
]
assert len(fetch_calls) > 0
@pytest.mark.parametrize(
("fail_command", "error_message"),
[
(
"rev-parse",
"ambiguous argument 'HEAD': unknown revision or path not in the working tree.",
),
("stash", "fatal: unable to write new index file"),
(
"fetch",
"fatal: unable to access 'https://github.com/test/repo/': Could not resolve host",
),
("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"),
],
)
def test_clone_or_update_recovers_from_git_failures(
tmp_path: Path, mock_run_git_command: Mock, fail_command: str, error_message: str
) -> None:
"""Test that repos are re-cloned when various git commands fail."""
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
# Use helper to set up old repo
_setup_old_repo(repo_dir)
# Track command call counts to make first call fail, subsequent calls succeed
call_counts: dict[str, int] = {}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
# Determine which command this is
cmd_type = _get_git_command_type(cmd)
# Track call count for this command type
if cmd_type:
call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1
# Fail on first call to the specified command, succeed on subsequent calls
if cmd_type == fail_command and call_counts[cmd_type] == 1:
raise GitCommandError(error_message)
# Default successful responses
if cmd_type == "rev-parse":
return "abc123"
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
result_dir, revert = git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# Verify recovery happened
call_list = mock_run_git_command.call_args_list
# Should have attempted the failing command
assert any(fail_command in str(c) for c in call_list)
# Should have called clone for recovery
assert any("clone" in str(c) for c in call_list)
# Verify the repo directory path is returned
assert result_dir == repo_dir
def test_clone_or_update_fails_when_recovery_also_fails(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that we don't infinitely recurse when recovery also fails."""
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
# Use helper to set up old repo
_setup_old_repo(repo_dir)
# Mock git command to fail on clone (simulating network failure during recovery)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "rev-parse":
# First time fails (broken repo)
raise GitCommandError(
"ambiguous argument 'HEAD': unknown revision or path not in the working tree."
)
if cmd_type == "clone":
# Clone also fails (recovery fails)
raise GitCommandError("fatal: unable to access repository")
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
# Should raise after one recovery attempt fails
with pytest.raises(GitCommandError, match="fatal: unable to access repository"):
git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# Verify we only tried to clone once (no infinite recursion)
call_list = mock_run_git_command.call_args_list
clone_calls = [c for c in call_list if "clone" in c[0][0]]
# Should have exactly one clone call (the recovery attempt that failed)
assert len(clone_calls) == 1
# Should have tried rev-parse once (which failed and triggered recovery)
rev_parse_calls = [c for c in call_list if "rev-parse" in c[0][0]]
assert len(rev_parse_calls) == 1
def test_clone_or_update_recover_broken_flag_prevents_second_recovery(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that _recover_broken=False prevents a second recovery attempt (tests the raise path)."""
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
# Use helper to set up old repo
_setup_old_repo(repo_dir)
# Track fetch calls to differentiate between first (in clone) and second (in recovery update)
call_counts: dict[str, int] = {}
# Mock git command to fail on fetch during recovery's ref checkout
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type:
call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1
# First attempt: rev-parse fails (broken repo)
if cmd_type == "rev-parse" and call_counts[cmd_type] == 1:
raise GitCommandError(
"ambiguous argument 'HEAD': unknown revision or path not in the working tree."
)
# Recovery: clone succeeds
if cmd_type == "clone":
return ""
# Recovery: fetch for ref checkout fails
# This happens in the clone path when ref is not None (line 80 in git.py)
if cmd_type == "fetch" and call_counts[cmd_type] == 1:
raise GitCommandError("fatal: couldn't find remote ref main")
# Default success
return "abc123" if cmd_type == "rev-parse" else ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
# Should raise on the fetch during recovery (when _recover_broken=False)
# This tests the critical "if not _recover_broken: raise" path
with pytest.raises(GitCommandError, match="fatal: couldn't find remote ref main"):
git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# Verify the sequence of events
call_list = mock_run_git_command.call_args_list
# Should have: rev-parse (fail, triggers recovery), clone (success),
# fetch (fail during ref checkout, raises because _recover_broken=False)
rev_parse_calls = [c for c in call_list if "rev-parse" in c[0][0]]
# Should have exactly one rev-parse call that failed
assert len(rev_parse_calls) == 1
clone_calls = [c for c in call_list if "clone" in c[0][0]]
# Should have exactly one clone call (the recovery attempt)
assert len(clone_calls) == 1
fetch_calls = [c for c in call_list if "fetch" in c[0][0]]
# Should have exactly one fetch call that failed (during ref checkout in recovery)
assert len(fetch_calls) == 1
def test_clone_or_update_recover_broken_flag_prevents_infinite_loop(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Test that _recover_broken=False prevents infinite recursion when repo persists."""
# This tests the critical "if not _recover_broken: raise" path at line 124-125
# Set up CORE.config_path so data_dir uses tmp_path
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
ref = "main"
domain = "test"
repo_dir = _compute_repo_dir(url, ref, domain)
# Use helper to set up old repo
_setup_old_repo(repo_dir)
# Mock shutil.rmtree to NOT actually delete the directory
# This simulates a scenario where deletion fails (permissions, etc.)
import unittest.mock
def mock_rmtree(path, *args, **kwargs):
# Don't actually delete - this causes the recursive call to still see the repo
pass
# Mock git commands to always fail on stash
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
cmd_type = _get_git_command_type(cmd)
if cmd_type == "rev-parse":
return "abc123"
if cmd_type == "stash":
# Always fails
raise GitCommandError("fatal: unable to write new index file")
return ""
mock_run_git_command.side_effect = git_command_side_effect
refresh = TimePeriodSeconds(days=1)
# Mock shutil.rmtree and test
# Should raise on the second attempt when _recover_broken=False
# This hits the "if not _recover_broken: raise" path
with (
unittest.mock.patch("esphome.git.shutil.rmtree", side_effect=mock_rmtree),
pytest.raises(GitCommandError, match="fatal: unable to write new index file"),
):
git.clone_or_update(
url=url,
ref=ref,
refresh=refresh,
domain=domain,
)
# Verify the sequence: stash fails twice (once triggering recovery, once raising)
call_list = mock_run_git_command.call_args_list
stash_calls = [c for c in call_list if "stash" in c[0][0]]
# Should have exactly two stash calls
assert len(stash_calls) == 2

View File

@@ -1062,7 +1062,7 @@ def test_upload_program_ota_with_file_arg(
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, "", Path("custom.bin")
["192.168.1.100"], 3232, None, Path("custom.bin")
)
@@ -1119,7 +1119,9 @@ def test_upload_program_ota_with_mqtt_resolution(
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware
)
@patch("esphome.__main__.importlib.import_module")
@@ -1976,3 +1978,292 @@ def test_command_clean_all_args_used() -> None:
# Verify the correct configuration paths were passed
mock_clean_all.assert_any_call(["/path/to/config1"])
mock_clean_all.assert_any_call(["/path/to/config2", "/path/to/config3"])
def test_upload_program_ota_static_ip_with_mqttip(
mock_mqtt_get_ip: Mock,
mock_run_ota: Mock,
tmp_path: Path,
) -> None:
"""Test upload_program with static IP and MQTTIP (issue #11260).
This tests the scenario where a device has manual_ip (static IP) configured
and MQTT is also configured. The devices list contains both the static IP
and "MQTTIP" magic string. This previously failed because only the first
device was checked for MQTT resolution.
"""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_mqtt_get_ip.return_value = ["192.168.2.50"] # Different subnet
mock_run_ota.return_value = (0, "192.168.1.100")
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
],
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
}
args = MockArgs(username="user", password="pass", client_id="client")
# Simulates choose_upload_log_host returning static IP + MQTTIP
devices = ["192.168.1.100", "MQTTIP"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.1.100"
# Verify MQTT was resolved
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
# Verify espota2.run_ota was called with both IPs
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware
)
def test_upload_program_ota_multiple_mqttip_resolves_once(
mock_mqtt_get_ip: Mock,
mock_run_ota: Mock,
tmp_path: Path,
) -> None:
"""Test that MQTT resolution only happens once even with multiple MQTT magic strings."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
mock_mqtt_get_ip.return_value = ["192.168.2.50", "192.168.2.51"]
mock_run_ota.return_value = (0, "192.168.2.50")
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
],
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
}
args = MockArgs(username="user", password="pass", client_id="client")
# Multiple MQTT magic strings in the list
devices = ["MQTTIP", "MQTT", "192.168.1.100"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.2.50"
# Verify MQTT was only resolved once despite multiple MQTT magic strings
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
# Verify espota2.run_ota was called with all unique IPs
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware
)
def test_upload_program_ota_mqttip_deduplication(
mock_mqtt_get_ip: Mock,
mock_run_ota: Mock,
tmp_path: Path,
) -> None:
"""Test that duplicate IPs are filtered when MQTT returns same IP as static IP."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
# MQTT returns the same IP as the static IP
mock_mqtt_get_ip.return_value = ["192.168.1.100"]
mock_run_ota.return_value = (0, "192.168.1.100")
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
],
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
}
args = MockArgs(username="user", password="pass", client_id="client")
devices = ["192.168.1.100", "MQTTIP"]
exit_code, host = upload_program(config, args, devices)
assert exit_code == 0
assert host == "192.168.1.100"
# Verify MQTT was resolved
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
# Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100)
# Note: Current implementation doesn't dedupe, so we'll get the IP twice
# This test documents current behavior - deduplication could be future enhancement
mock_run_ota.assert_called_once()
call_args = mock_run_ota.call_args[0]
# Should contain both the original IP and MQTT-resolved IP (even if duplicate)
assert "192.168.1.100" in call_args[0]
@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_static_ip_with_mqttip(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
) -> None:
"""Test show_logs with static IP and MQTTIP (issue #11260).
This tests the scenario where a device has manual_ip (static IP) configured
and MQTT is also configured. The devices list contains both the static IP
and "MQTTIP" magic string.
"""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
mock_mqtt_get_ip.return_value = ["192.168.2.50"]
args = MockArgs(username="user", password="pass", client_id="client")
# Simulates choose_upload_log_host returning static IP + MQTTIP
devices = ["192.168.1.100", "MQTTIP"]
result = show_logs(CORE.config, args, devices)
assert result == 0
# Verify MQTT was resolved
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Verify run_logs was called with both IPs
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100", "192.168.2.50"]
)
@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_multiple_mqttip_resolves_once(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
) -> None:
"""Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
mock_mqtt_get_ip.return_value = ["192.168.2.50", "192.168.2.51"]
args = MockArgs(username="user", password="pass", client_id="client")
# Multiple MQTT magic strings in the list
devices = ["MQTTIP", "192.168.1.100", "MQTT"]
result = show_logs(CORE.config, args, devices)
assert result == 0
# Verify MQTT was only resolved once despite multiple MQTT magic strings
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs)
# Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution
# The _resolve_network_devices helper filters out both after first resolution
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.2.50", "192.168.2.51", "192.168.1.100"]
)
def test_upload_program_ota_mqtt_timeout_fallback(
mock_mqtt_get_ip: Mock,
mock_run_ota: Mock,
tmp_path: Path,
) -> None:
"""Test upload_program falls back to other devices when MQTT times out."""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
# MQTT times out
mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT")
mock_run_ota.return_value = (0, "192.168.1.100")
config = {
CONF_OTA: [
{
CONF_PLATFORM: CONF_ESPHOME,
CONF_PORT: 3232,
}
],
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
}
args = MockArgs(username="user", password="pass", client_id="client")
# Static IP first, MQTTIP second
devices = ["192.168.1.100", "MQTTIP"]
exit_code, host = upload_program(config, args, devices)
# Should succeed using the static IP even though MQTT failed
assert exit_code == 0
assert host == "192.168.1.100"
# Verify MQTT was attempted
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
# Verify espota2.run_ota was called with only the static IP (MQTT failed)
expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware
)
@patch("esphome.components.api.client.run_logs")
def test_show_logs_api_mqtt_timeout_fallback(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
) -> None:
"""Test show_logs falls back to other devices when MQTT times out."""
setup_core(
config={
"logger": {},
CONF_API: {},
CONF_MQTT: {CONF_BROKER: "mqtt.local"},
},
platform=PLATFORM_ESP32,
)
mock_run_logs.return_value = 0
# MQTT times out
mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT")
args = MockArgs(username="user", password="pass", client_id="client")
# Static IP first, MQTTIP second
devices = ["192.168.1.100", "MQTTIP"]
result = show_logs(CORE.config, args, devices)
# Should succeed using the static IP even though MQTT failed
assert result == 0
# Verify MQTT was attempted
mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client")
# Verify run_logs was called with only the static IP (MQTT failed)
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])

Some files were not shown because too many files have changed in this diff Show More