Merge branch 'dev' into ota-idf-lazy-erase

This commit is contained in:
J. Nick Koston
2026-08-22 21:17:43 -05:00
committed by GitHub
195 changed files with 1681 additions and 938 deletions
@@ -0,0 +1,10 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The scan list helper is header-only and needs none of the component's real
# dependencies. Pulling them in breaks the host build: web_server_base
# includes ESPAsyncWebServer.h and ota.web_server includes md5/md5.h, neither
# of which exists there.
manifest.dependencies = []
manifest.auto_load = []
@@ -0,0 +1,130 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <string>
#include <vector>
#include "esphome/components/captive_portal/scan_list.h"
namespace esphome::captive_portal::testing {
namespace {
// Stand-in for wifi::WiFiScanResult, which does not compile on the host.
struct Entry {
std::string ssid;
int8_t rssi;
bool with_auth{true};
bool is_hidden{false};
// Compares length and bytes like CompactString does, so an embedded NUL counts.
bool ssid_equals(const Entry &other) const { return this->ssid == other.ssid; }
int8_t get_rssi() const { return this->rssi; }
bool get_with_auth() const { return this->with_auth; }
bool get_is_hidden() const { return this->is_hidden; }
};
// One row as the portal would emit it.
struct Row {
std::string ssid;
int8_t rssi;
bool lock;
bool operator==(const Row &rhs) const { return ssid == rhs.ssid && rssi == rhs.rssi && lock == rhs.lock; }
};
// Walk the results the way handle_config does and collect the rows that survive.
std::vector<Row> rows(const std::vector<Entry> &results) {
std::vector<Row> out;
for (size_t i = 0; i < results.size(); i++) {
bool with_auth = false;
if (!should_show_scan_entry(results, results[i], with_auth))
continue;
out.push_back({results[i].ssid, results[i].rssi, with_auth});
}
return out;
}
} // namespace
TEST(ScanList, SingleEntryShown) {
std::vector<Entry> results = {{"Home", -60}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -60, true}}));
}
TEST(ScanList, DistinctSsidsAllShownInOrder) {
std::vector<Entry> results = {{"Home", -60}, {"Guest", -70}, {"Cafe", -40}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -60, true}, {"Guest", -70, true}, {"Cafe", -40, true}}));
}
// Results are ordered by connection preference, not RSSI, so the strongest entry
// can sit anywhere in the list.
TEST(ScanList, SameSsidKeepsStrongest) {
std::vector<Entry> results = {{"Home", -70}, {"Home", -50}, {"Home", -60}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -50, true}}));
}
TEST(ScanList, EqualRssiKeepsFirst) {
std::vector<Entry> results = {{"Home", -60}, {"Home", -60}, {"Home", -60}};
bool with_auth = false;
EXPECT_TRUE(should_show_scan_entry(results, results[0], with_auth));
EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth));
EXPECT_FALSE(should_show_scan_entry(results, results[2], with_auth));
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -60, true}}));
}
// with_auth is an out-parameter that must only be written for a shown entry.
TEST(ScanList, WithAuthUntouchedWhenNotShown) {
std::vector<Entry> results = {{"Home", -50, false}, {"Home", -70, true}};
bool with_auth = false;
EXPECT_FALSE(should_show_scan_entry(results, results[1], with_auth));
EXPECT_FALSE(with_auth);
}
TEST(ScanList, DuplicatesInterleavedWithOtherNetworks) {
std::vector<Entry> results = {{"Home", -70}, {"Guest", -55}, {"Home", -50}, {"Guest", -65}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Guest", -55, true}, {"Home", -50, true}}));
}
// Hidden networks scan with an empty SSID. They are never listed and do not
// collapse into each other or into anything else.
TEST(ScanList, HiddenEntriesNeverShown) {
std::vector<Entry> results = {{"", -40, true, true}, {"Home", -70}, {"", -30, true, true}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -70, true}}));
}
// On ESP8266 the hidden flag comes from the driver alongside a real SSID, so a
// hidden access point can share its name with a visible one. It must not
// outrank that visible entry and leave the network unlisted.
TEST(ScanList, HiddenEntryDoesNotSuppressVisibleSameSsid) {
std::vector<Entry> results = {{"Home", -40, true, true}, {"Home", -70}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Home", -70, true}}));
}
// An open access point and a secured one sharing an SSID collapse to one row that
// still asks for a password, whichever of them is strongest.
TEST(ScanList, LockSetWhenAnyEntryRequiresAuth) {
std::vector<Entry> open_stronger = {{"Home", -50, false}, {"Home", -70, true}};
EXPECT_EQ(rows(open_stronger), (std::vector<Row>{{"Home", -50, true}}));
std::vector<Entry> secured_stronger = {{"Home", -70, false}, {"Home", -50, true}};
EXPECT_EQ(rows(secured_stronger), (std::vector<Row>{{"Home", -50, true}}));
}
TEST(ScanList, LockClearWhenEveryEntryIsOpen) {
std::vector<Entry> results = {{"Cafe", -60, false}, {"Cafe", -50, false}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Cafe", -50, false}}));
}
// The auth flag of an unrelated network must not leak into another SSID's row.
TEST(ScanList, LockIsPerSsid) {
std::vector<Entry> results = {{"Cafe", -60, false}, {"Home", -50, true}};
EXPECT_EQ(rows(results), (std::vector<Row>{{"Cafe", -60, false}, {"Home", -50, true}}));
}
TEST(ScanList, EmptyListShowsNothing) {
std::vector<Entry> results;
EXPECT_TRUE(rows(results).empty());
}
} // namespace esphome::captive_portal::testing
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -0,0 +1,73 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
emontx: !include common.yaml
# Validate that each sensor type gets the correct default state_class,
# unit_of_measurement, device_class, and accuracy_decimals when NO overrides
# are provided. The values are intentionally omitted so apply_tag_defaults is
# exercised, not the user-override path.
sensor:
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
# device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: E1
name: Energy 1
emontx_id: test_emontx
# Power sensor (P prefix): expects state_class=measurement, unit=W,
# device_class=power, accuracy_decimals=0
- platform: emontx
tag_name: P1
name: Power 1
emontx_id: test_emontx
# Voltage sensor (V prefix): expects state_class=measurement, unit=V,
# device_class=voltage, accuracy_decimals=2
- platform: emontx
tag_name: V1
name: Voltage 1
emontx_id: test_emontx
# Current sensor (I prefix): expects state_class=measurement, unit=A,
# device_class=current, accuracy_decimals=2
- platform: emontx
tag_name: I1
name: Current 1
emontx_id: test_emontx
# Temperature sensor (T prefix): expects state_class=measurement, unit=°C,
# device_class=temperature, accuracy_decimals=2
- platform: emontx
tag_name: T1
name: Temperature 1
emontx_id: test_emontx
# Pulse sensor (PULSE pattern): expects state_class=total_increasing,
# unit=pulses, device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: PULSE1
name: Pulse 1
emontx_id: test_emontx
# Power factor sensor (PF pattern): expects state_class=measurement,
# device_class=power_factor, accuracy_decimals=2
- platform: emontx
tag_name: PF1
name: Power Factor 1
emontx_id: test_emontx
# Unknown tag: no prefix match, falls back to state_class=measurement,
# accuracy_decimals=0
- platform: emontx
tag_name: CUSTOM1
name: Custom sensor
emontx_id: test_emontx
# User override: verify that explicit values are respected and not clobbered
- platform: emontx
tag_name: E2
name: Energy 2 (user override)
emontx_id: test_emontx
state_class: measurement
accuracy_decimals: 3
@@ -6,3 +6,6 @@ update:
type: embedded
path: $component_dir/test_firmware.bin
sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31
on_update_available:
then:
- logger.log: "Coprocessor update available"
@@ -8,3 +8,6 @@ update:
type: http
source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json
update_interval: 6h
on_update_available:
then:
- logger.log: "Coprocessor update available"
@@ -1,7 +1,67 @@
#include <array>
#include <utility>
#include "../common.h"
namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
const auto mapping = TemperatureMapping();
for (int temperature = 16; temperature <= 31; ++temperature) {
EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature);
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
}
const auto traits = sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
TestableMitsubishiCN105Climate sut;
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
sut.set_use_fahrenheit(true);
const std::array cases{
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f},
std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f},
std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f},
std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f},
std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f},
};
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) {
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
const std::array cases{
std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f},
std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f},
};
for (const auto &[celsius, fahrenheit] : cases) {
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit);
}
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut;
@@ -73,6 +73,7 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
void set_use_fahrenheit(bool value) { this->component_.set_use_fahrenheit(value); }
protected:
MitsubishiCN105Component component_;
@@ -3,6 +3,7 @@ mitsubishi_cn105:
uart_id: uart_bus
update_interval: 30s
telemetry_request_min_interval: 120s
use_fahrenheit: true
vane:
on_state:
- logger.log:
@@ -0,0 +1,7 @@
remote_transmitter:
id: xmitr
pin: GPIO26
carrier_duty_percent: 50%
packages:
buttons: !include common-buttons.yaml
@@ -0,0 +1,7 @@
remote_transmitter:
id: xmitr
pin: GPIO12
carrier_duty_percent: 50%
packages:
buttons: !include common-buttons.yaml
+1 -1
View File
@@ -1,6 +1,6 @@
# `sendspin.switch` action enables the controller role, so we use a standalone test
packages:
base: !include common.yaml
sendspin: !include common.yaml
wifi:
on_connect:
@@ -0,0 +1,5 @@
packages:
sendspin_hub: !include common-hub.yaml
ethernet:
type: OPENETH
@@ -0,0 +1,6 @@
psram:
mode: quad
sendspin:
id: sendspin_hub_id
task_stack_in_psram: true
@@ -1,4 +1,5 @@
<<: !include common.yaml
packages:
sendspin: !include common.yaml
media_player:
- platform: sendspin
@@ -1,4 +1,5 @@
<<: !include common.yaml
packages:
sendspin: !include common.yaml
media_source:
- platform: sendspin
+2 -1
View File
@@ -1,4 +1,5 @@
<<: !include common.yaml
packages:
sendspin: !include common.yaml
sensor:
- platform: sendspin
@@ -1,4 +1,5 @@
<<: !include common.yaml
packages:
sendspin: !include common.yaml
text_sensor:
- platform: sendspin
+3 -7
View File
@@ -1,9 +1,5 @@
packages:
sendspin_hub: !include common-hub.yaml
wifi:
ap:
psram:
mode: quad
sendspin:
id: sendspin_hub_id
task_stack_in_psram: true
@@ -1 +1,2 @@
<<: !include common-action.yaml
packages:
sendspin: !include common-action.yaml
@@ -1,9 +1,2 @@
ethernet:
type: OPENETH
psram:
mode: quad
sendspin:
id: sendspin_hub_id
task_stack_in_psram: true
packages:
sendspin: !include common-ethernet.yaml
@@ -1 +1,2 @@
<<: !include common-media_player.yaml
packages:
sendspin: !include common-media_player.yaml
@@ -1 +1,2 @@
<<: !include common-media_source.yaml
packages:
sendspin: !include common-media_source.yaml
@@ -1 +1,2 @@
<<: !include common-sensor.yaml
packages:
sendspin: !include common-sensor.yaml
@@ -1 +1,2 @@
<<: !include common-text_sensor.yaml
packages:
sendspin: !include common-text_sensor.yaml
@@ -1 +1,2 @@
<<: !include common.yaml
packages:
sendspin: !include common.yaml