Merge remote-tracking branch 'origin/dev' into feat/nrf52_pwm

This commit is contained in:
Tomasz Duda
2026-08-02 16:00:00 +02:00
1098 changed files with 45588 additions and 14294 deletions
+3
View File
@@ -28,6 +28,7 @@ create an `__init__.py` in your component's test directory and define `override_
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Re-enable the component's own to_code (needed when the component must
# emit C++ setup code that the test binary depends on at link time).
@@ -39,6 +40,7 @@ Or supply a lightweight stub instead of the real `to_code`:
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config):
# Only emit what the C++ tests actually need
@@ -54,6 +56,7 @@ e.g. `tests/components/my_sensor/sensor/__init__.py`):
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
```
+11 -8
View File
@@ -1,23 +1,26 @@
animation:
- id: rgb565_animation
image:
- platform: animation
id: rgb565_animation
file: $component_dir/anim.gif
type: RGB565
transparency: opaque
resize: 50x50
- id: rgb_animation
- platform: animation
id: rgb_animation
file: $component_dir/anim.apng
type: RGB
transparency: chroma_key
resize: 50x50
- id: grayscale_animation
- platform: animation
id: grayscale_animation
file: $component_dir/anim.apng
type: grayscale
display:
lambda: |-
id(rgb565_animation).next_frame();
id(rgb_animation1).next_frame();
id(grayscale_animation2).next_frame();
id(rgb_animation).next_frame();
id(grayscale_animation).next_frame();
it.image(0, 0, rgb565_animation);
it.image(120, 0, rgb_animation1);
it.image(240, 0, grayscale_animation2);
it.image(120, 0, rgb_animation);
it.image(240, 0, grayscale_animation);
@@ -0,0 +1,16 @@
# Legacy top-level `animation:` form (deprecated; migrates to
# `platform: animation`). Config-only test exercising the deprecation path.
display:
- platform: sdl
id: animation_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
animation:
- id: legacy_animation
file: $component_dir/anim.gif
type: RGB565
transparency: opaque
resize: 50x50
@@ -1,5 +1,11 @@
<<: !include common-base.yaml
packages:
common: !include common-base.yaml
wifi:
ssid: MySSID
password: password1
# Encryption enabled without a key: compiles both frame helpers so the key
# can be provisioned at runtime (zero-PSK noise or deprecated plaintext)
api:
encryption:
+16
View File
@@ -0,0 +1,16 @@
# Declares the component graph the C++ unit test build needs so that the aqi
# component's sources (which include sensor.h) compile. to_code is suppressed by
# the test harness; this only pulls the sensor + aqi source/include paths in.
# Loaded with plain yaml.safe_load, so avoid lambdas / ESPHome-tagged values here.
sensor:
- platform: template
id: pm25_sensor
name: "PM2.5"
- platform: template
id: pm10_sensor
name: "PM10"
- platform: aqi
name: "AQI"
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: AQI
+7
View File
@@ -20,3 +20,10 @@ sensor:
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: CAQI
- platform: aqi
name: "Air Quality Index (AQI, extended)"
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: AQI
extended_range: true
@@ -0,0 +1,85 @@
#include <gtest/gtest.h>
#include "esphome/components/aqi/aqi_calculator.h"
#include "esphome/components/aqi/caqi_calculator.h"
namespace esphome::aqi::testing {
// US AQI (EPA 2024): PM2.5 225.5-500.4 -> 301-500, PM10 425-604 -> 301-500.
TEST(USAQI, LowRangeUnaffectedByExtendedFlag) {
AQICalculator calc;
// PM2.5 25 drives over PM10 50; well below the top band, so the flag changes nothing.
EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 81);
EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, true), 81);
}
TEST(USAQI, HazardousInterpolatesNotPinnedAt301) {
AQICalculator calc;
// Regression guard: the old FLT_MAX top bucket collapsed every hazardous reading to 301.
EXPECT_EQ(calc.get_aqi(225.5f, 0.0f, false), 301); // band start
EXPECT_EQ(calc.get_aqi(250.0f, 0.0f, false), 319); // interpolated, not 301
EXPECT_EQ(calc.get_aqi(500.4f, 0.0f, false), 500); // band top
}
TEST(USAQI, DefaultClampsAtStandardMaximum) {
AQICalculator calc;
EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, false), 500);
EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, false), 500);
EXPECT_EQ(calc.get_aqi(0.0f, 604.0f, false), 500); // PM10 top breakpoint
}
TEST(USAQI, ExtendedRangeExtrapolatesBeyond500) {
AQICalculator calc;
EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, true), 572);
EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, true), 862);
EXPECT_EQ(calc.get_aqi(0.0f, 700.0f, true), 607); // PM10 extrapolated past 500
}
TEST(USAQI, ExtendedRangeSaturatesUint16NoWraparound) {
AQICalculator calc;
// An absurd concentration would overflow uint16_t; it must saturate, not wrap to a small value.
EXPECT_EQ(calc.get_aqi(100000.0f, 0.0f, true), 65535);
}
TEST(USAQI, WorseOfTwoPollutantsWins) {
AQICalculator calc;
// PM10 604 -> 500 dominates PM2.5 25 -> 81.
EXPECT_EQ(calc.get_aqi(25.0f, 604.0f, false), 500);
}
// CAQI (CITEAIR): no maximum by spec -- the top ">100" class is open, so it is always unbounded
// and the extended_range flag does not apply.
TEST(CAQI, LowRange) {
CAQICalculator calc;
EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 50);
}
TEST(CAQI, ContinuousAt100NoPinAt101) {
CAQICalculator calc;
// Old code pinned everything above the top breakpoint to 101; now it reaches exactly 100.
EXPECT_EQ(calc.get_aqi(110.1f, 0.0f, false), 100);
}
TEST(CAQI, UnboundedAboveTopBand) {
CAQICalculator calc;
EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, false), 139);
EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, false), 925);
}
TEST(CAQI, ExtendedRangeFlagIsIgnored) {
CAQICalculator calc;
// CAQI is always unbounded, so the flag must make no difference either way.
EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, true), calc.get_aqi(200.0f, 0.0f, false));
EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, true), calc.get_aqi(2000.0f, 0.0f, false));
}
TEST(CAQI, SaturatesUint16NoWraparound) {
CAQICalculator calc;
// CAQI is unbounded, so an extreme reading can extrapolate past uint16_t; it must saturate,
// not wrap around to a small (falsely "good") value.
EXPECT_EQ(calc.get_aqi(200000.0f, 0.0f, false), 65535);
}
} // namespace esphome::aqi::testing
+2
View File
@@ -0,0 +1,2 @@
bk72xx_ble:
enable_on_boot: true
@@ -0,0 +1,2 @@
packages:
bk72xx_ble: !include common.yaml
@@ -0,0 +1,11 @@
bk72xx_ble_tracker:
id: ble_tracker
scan_parameters:
# Boundary coverage: the documented 2.5 ms floor on window (expressible only
# via the microsecond-accurate validation), a non-round interval exercising the
# 0.625 ms unit conversion without collapsing onto the window's unit count,
# and the non-continuous config path.
interval: 5000us
window: 2500us
duration: 5min
continuous: false
@@ -0,0 +1,7 @@
bk72xx_ble_tracker:
id: ble_tracker
scan_parameters:
interval: 100ms
window: 30ms
duration: 5min
continuous: true
@@ -0,0 +1,2 @@
packages:
bk72xx_ble_tracker: !include common-boundary.yaml
@@ -0,0 +1,2 @@
packages:
bk72xx_ble_tracker: !include common.yaml
@@ -0,0 +1,12 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# resolve_irk() is compiled only when a sensor configures irk:
# (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has
# no sensors, so emit the define here to put the real IRK path under test.
async def to_code_testing(config):
cg.add_define("USE_BLE_DEVICE_IRK")
manifest.to_code = to_code_testing
@@ -0,0 +1,46 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// from_scan_result() ingests BLE controller order (LSB-first); the public
// accessors must expose the historical esp32 semantics: address() in printable
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str()
// printed MSB-first.
namespace {
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
} // namespace
TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) {
ESPBTDevice device;
device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0);
const uint8_t *raw = device.address();
EXPECT_EQ(raw[0], 0xaa); // MSB first, like ESP-IDF's bda
EXPECT_EQ(raw[5], 0xff);
EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL);
EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF");
}
// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement
// callback delivers into the printable-order uint64 the native API speaks — the
// value esp32_ble::ble_addr_to_uint64() has always produced for that address.
TEST(BleDeviceAddress, MacLsbFirstToUint64MatchesWireValue) {
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), 0xAABBCCDDEEFFULL);
}
// The helper and the parsed-device accessor are two routes to the same wire
// value: byte order must agree no matter which path an advertisement takes.
TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) {
ESPBTDevice device;
device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0);
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64());
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,56 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ble_device_base/ble_aes_ccm.h"
namespace esphome::ble_device_base::testing {
// Reference vector generated with Python `cryptography` AESCCM(tag_length=4),
// using the same AES-128-CCM parameters BTHome advertisements use: a 16-byte
// key, a 13-byte nonce, a 4-byte authentication tag and no associated data.
namespace {
const uint8_t KEY[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
const uint8_t NONCE[13] = {0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c};
const uint8_t CIPHERTEXT[7] = {0x68, 0xb4, 0xf6, 0xc5, 0x2b, 0xf8, 0xaf};
const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56};
const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01};
} // namespace
TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) {
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
sizeof(TAG)));
EXPECT_EQ(0, memcmp(out, PLAINTEXT, sizeof(PLAINTEXT)));
}
TEST(BleAesCcm, RejectsTamperedTag) {
uint8_t bad_tag[sizeof(TAG)];
memcpy(bad_tag, TAG, sizeof(TAG));
bad_tag[0] ^= 0x01;
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, bad_tag,
sizeof(bad_tag)));
}
TEST(BleAesCcm, RejectsTamperedCiphertext) {
uint8_t bad_ct[sizeof(CIPHERTEXT)];
memcpy(bad_ct, CIPHERTEXT, sizeof(CIPHERTEXT));
bad_ct[0] ^= 0x01;
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_FALSE(
aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, bad_ct, sizeof(bad_ct), out, TAG, sizeof(TAG)));
}
TEST(BleAesCcm, RejectsWrongKey) {
uint8_t bad_key[sizeof(KEY)];
memcpy(bad_key, KEY, sizeof(KEY));
bad_key[0] ^= 0xFF;
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_FALSE(aes_ccm_auth_decrypt(bad_key, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
sizeof(TAG)));
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,41 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// A 16- or 32-bit UUID must compare equal to its 128-bit Bluetooth Base UUID form, matching
// esp32_ble_tracker. The 128-bit raw is the base UUID (LSB-first) with the short value at
// bytes 12.. : here 0x1234 -> bytes [12]=0x34, [13]=0x12.
TEST(BleDeviceUuid, ShortFormMatchesEquivalentLongForm) {
const ESPBTUUID u16 = ESPBTUUID::from_uint16(0x1234);
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128);
EXPECT_TRUE(u16 == u128);
EXPECT_TRUE(u128 == u16); // symmetric
}
TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) {
const ESPBTUUID u32 = ESPBTUUID::from_uint32(0x1122AAFF);
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0xFF, 0xAA, 0x22, 0x11};
const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128);
EXPECT_TRUE(u32 == u128);
}
TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) {
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235));
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
// Same low bytes but a non-base prefix is a genuinely different 128-bit UUID.
uint8_t custom[16];
memcpy(custom, raw128, 16);
custom[0] ^= 0x01;
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_raw(custom));
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,48 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// Reference vector generated with Python `cryptography` AES-128-ECB following
// the RPA resolution procedure (Bluetooth Core, Vol 3 Part H §2.2.2):
// hash = e(IRK, prand), where prand is the top 3 address bytes and the hash
// must equal the low 3 address bytes.
namespace {
const uint8_t IRK[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
// 4A:2B:7C:FB:7B:21 — prand 4A:2B:7C (two MSBs = 01, an RPA), hash FB:7B:21.
const uint8_t RPA_LSB_FIRST[6] = {0x21, 0x7b, 0xfb, 0x7c, 0x2b, 0x4a};
ESPBTDevice make_device(const uint8_t mac_lsb_first[6]) {
ESPBTDevice device;
device.from_scan_result(mac_lsb_first, /*rssi=*/-60, /*addr_type=*/BLE_ADDR_TYPE_RPA_RANDOM, nullptr, 0);
return device;
}
} // namespace
TEST(BleIrk, ResolvesMatchingRpa) {
ESPBTDevice device = make_device(RPA_LSB_FIRST);
EXPECT_TRUE(device.resolve_irk(IRK));
}
TEST(BleIrk, RejectsWrongIrk) {
uint8_t wrong_irk[16];
for (int i = 0; i < 16; i++)
wrong_irk[i] = IRK[i] ^ 0xff;
ESPBTDevice device = make_device(RPA_LSB_FIRST);
EXPECT_FALSE(device.resolve_irk(wrong_irk));
}
TEST(BleIrk, RejectsWrongAddress) {
uint8_t other_mac[6];
for (int i = 0; i < 6; i++)
other_mac[i] = RPA_LSB_FIRST[i];
other_mac[0] ^= 0x01; // corrupt one hash byte
ESPBTDevice device = make_device(other_mac);
EXPECT_FALSE(device.resolve_irk(IRK));
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,101 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_hub.h"
namespace esphome::ble_device_base::testing {
// Exercises the hub contract around RawAdvertisementCallback, not just the
// struct: a hub stores one slot via set_raw_advertisement_callback(), fires it
// only when set ("no subscriber" is the default-constructed slot), and a new
// registration replaces the old ("one consumer at a time").
//
// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against
// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are
// pinned here through a minimal host BLEHub implementation instead.
namespace {
class FakeHub : public BLEHub {
public:
void register_listener(ESPBTDeviceListener *listener) override {}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; }
HubCapabilities get_capabilities() const override { return {false, false, false}; }
void get_adapter_mac(uint8_t out[6]) override {}
bool scan_running() override { return false; }
bool scan_active() override { return false; }
/// The emit path every tracker implements: fire only when a subscriber is set.
void emit(const RawAdvertisement &adv) {
if (this->callback_.is_set())
this->callback_.invoke(adv);
}
protected:
RawAdvertisementCallback callback_; // default-constructed: no subscriber
};
struct CapturingSubscriber {
RawAdvertisement last{};
int calls{0};
static void trampoline(void *self, const RawAdvertisement &adv) {
auto *sub = static_cast<CapturingSubscriber *>(self);
sub->last = adv;
sub->calls++;
}
};
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00};
RawAdvertisement make_test_adv() {
return RawAdvertisement{
.mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
}
} // namespace
TEST(RawAdvertisementCallback, DefaultConstructedSlotIsNotSet) {
const RawAdvertisementCallback callback{};
EXPECT_FALSE(callback.is_set());
}
TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) {
FakeHub hub;
CapturingSubscriber subscriber;
hub.set_raw_advertisement_callback({&subscriber, CapturingSubscriber::trampoline});
hub.emit(make_test_adv());
ASSERT_EQ(subscriber.calls, 1);
EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST);
EXPECT_EQ(subscriber.last.data, ADV_DATA);
EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA));
EXPECT_EQ(subscriber.last.rssi, -63);
EXPECT_EQ(subscriber.last.addr_type, 1);
}
TEST(RawAdvertisementCallback, NoSubscriberDoesNotFire) {
FakeHub hub;
// No set_raw_advertisement_callback(): emitting must be a guarded no-op,
// not a jump through a garbage pointer.
hub.emit(make_test_adv());
}
TEST(RawAdvertisementCallback, NewSubscriberReplacesOld) {
FakeHub hub;
CapturingSubscriber first;
CapturingSubscriber second;
hub.set_raw_advertisement_callback({&first, CapturingSubscriber::trampoline});
hub.set_raw_advertisement_callback({&second, CapturingSubscriber::trampoline});
hub.emit(make_test_adv());
EXPECT_EQ(first.calls, 0); // one consumer at a time
ASSERT_EQ(second.calls, 1);
EXPECT_EQ(second.last.rssi, -63);
}
} // namespace esphome::ble_device_base::testing
+9
View File
@@ -17,6 +17,15 @@ cc1101:
sync0: 0x91
sync1: 0xD3
num_preamble: 2
foc_bs_cs_gate: true
foc_pre_k: "2K"
foc_post_k: "K/2"
foc_limit: "BW/4"
bs_pre_ki: "3KI"
bs_pre_kp: "4KP"
bs_post_ki: "KI/2"
bs_post_kp: "KP"
bs_limit: "12.5%"
on_packet:
then:
- lambda: |-
+134
View File
@@ -0,0 +1,134 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome::testing {
namespace {
// Large enough that none of the inputs below are ever dropped.
constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1;
// Escape into a stack buffer and return the result as a string so the expectations stay readable.
std::string escape(const std::string &value) {
char buf[TEST_BUFFER_SIZE];
return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()));
}
// Same, but with the short control forms turned off.
std::string escape_long(const std::string &value) {
char buf[TEST_BUFFER_SIZE];
return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()), false);
}
} // namespace
// Plain ASCII with no special characters is passed through unchanged.
TEST(JsonEscape, PlainStringUnchanged) {
EXPECT_EQ(escape("MyNetwork"), "MyNetwork");
EXPECT_EQ(escape(""), "");
}
// A double quote is escaped so it does not terminate the surrounding JSON string.
TEST(JsonEscape, EscapesDoubleQuote) {
EXPECT_EQ(escape("a\"b"), "a\\\"b");
// A double quote followed by other characters stays inside the JSON string.
EXPECT_EQ(escape("\">end"), "\\\">end");
}
// A backslash is doubled so it does not start an escape sequence in the output.
TEST(JsonEscape, EscapesBackslash) {
EXPECT_EQ(escape("a\\b"), "a\\\\b");
// A trailing backslash must not escape the closing quote of the JSON string.
EXPECT_EQ(escape("net\\"), "net\\\\");
}
// The control characters with short JSON forms use those forms.
TEST(JsonEscape, EscapesShortFormControls) {
EXPECT_EQ(escape("\n"), "\\n");
EXPECT_EQ(escape("\r"), "\\r");
EXPECT_EQ(escape("\t"), "\\t");
EXPECT_EQ(escape("\b"), "\\b");
EXPECT_EQ(escape("\f"), "\\f");
}
// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex.
TEST(JsonEscape, EscapesOtherControlsAsUnicode) {
EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000");
EXPECT_EQ(escape("\x01"), "\\u0001");
EXPECT_EQ(escape("\x10"), "\\u0010");
EXPECT_EQ(escape("\x1f"), "\\u001f");
// 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper.
EXPECT_EQ(escape("\x7f"), "\x7f");
}
// With the short forms turned off, every control character is written as \u00XX instead.
TEST(JsonEscape, LongControlEscapes) {
EXPECT_EQ(escape_long("\n"), "\\u000a");
EXPECT_EQ(escape_long("\r"), "\\u000d");
EXPECT_EQ(escape_long("\t"), "\\u0009");
EXPECT_EQ(escape_long("\b"), "\\u0008");
EXPECT_EQ(escape_long("\f"), "\\u000c");
// Controls without a short form are unaffected by the flag.
EXPECT_EQ(escape_long("\x01"), "\\u0001");
}
// The flag only affects control characters. A quote or backslash is never written as \u00XX, because that form is
// no shorter and both modes have always emitted the two character escape.
TEST(JsonEscape, LongModeStillUsesTwoCharQuoteAndBackslash) {
EXPECT_EQ(escape_long("a\"b"), "a\\\"b");
EXPECT_EQ(escape_long("a\\b"), "a\\\\b");
// Ordinary text is untouched in either mode.
EXPECT_EQ(escape_long("MyDevice"), "MyDevice");
}
// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim.
TEST(JsonEscape, PassesThroughUtf8) {
// "café" in UTF-8 (é == 0xC3 0xA9).
EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9");
// Emoji (📶, 4-byte UTF-8) survives unchanged.
EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6");
}
// A mix of special and normal characters is escaped in place without disturbing the rest.
TEST(JsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); }
// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly.
TEST(JsonEscape, WorstCaseInputFitsExactly) {
constexpr size_t input_len = 8;
char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1];
const std::string input(input_len, '\x01');
std::string expected;
for (size_t i = 0; i < input_len; i++)
expected += "\\u0001";
EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected);
}
// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null
// terminated.
TEST(JsonEscape, DropsEscapeThatWouldNotFit) {
// Room for one \u00XX sequence plus the null terminator, but two are requested.
char buf[JSON_ESCAPE_MAX_EXPANSION + 1];
const std::string input(2, '\x01');
const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size()));
EXPECT_EQ(result, "\\u0001");
EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0');
}
// Plain characters are truncated at the buffer size, leaving room for the null terminator.
TEST(JsonEscape, TruncatesPlainInput) {
char buf[5];
const std::string input(20, 'a');
EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa");
}
// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing.
TEST(JsonEscape, EmptyBufferIsSafe) {
const std::string input("test");
EXPECT_STREQ(json_escape_into_buffer(std::span<char>(), StringRef(input.c_str(), input.size())), "");
}
} // namespace esphome::testing
@@ -0,0 +1,96 @@
// Exercises the no-atomics LockFreeQueue implementation (PlainAtomic indices —
// the path used on cores without atomic RMW instructions, currently BK72xx).
// The define is forced before the include so this TU deterministically compiles
// that path regardless of the host's default thread model; no other test TU
// instantiates this template, so the differing definition is confined here.
#define ESPHOME_THREAD_MULTI_NO_ATOMICS
#include "esphome/core/lock_free_queue.h"
#include <gtest/gtest.h>
namespace esphome::core::testing {
TEST(LockFreeQueueNoAtomics, EmptyPopReturnsNull) {
esphome::LockFreeQueue<int, 4> q;
EXPECT_EQ(q.pop(), nullptr);
EXPECT_TRUE(q.empty());
EXPECT_FALSE(q.full());
EXPECT_EQ(q.size(), 0u);
}
TEST(LockFreeQueueNoAtomics, FifoOrder) {
esphome::LockFreeQueue<int, 4> q;
int a = 1, b = 2, c = 3;
EXPECT_TRUE(q.push(&a));
EXPECT_TRUE(q.push(&b));
EXPECT_TRUE(q.push(&c));
EXPECT_EQ(q.size(), 3u);
EXPECT_EQ(q.pop(), &a);
EXPECT_EQ(q.pop(), &b);
EXPECT_EQ(q.pop(), &c);
EXPECT_EQ(q.pop(), nullptr);
}
TEST(LockFreeQueueNoAtomics, CapacityIsSizeMinusOne) {
esphome::LockFreeQueue<int, 4> q;
int v[4] = {0, 1, 2, 3};
EXPECT_TRUE(q.push(&v[0]));
EXPECT_TRUE(q.push(&v[1]));
EXPECT_TRUE(q.push(&v[2]));
EXPECT_TRUE(q.full());
// Ring reserves one slot: the SIZEth push fails and is counted as dropped.
EXPECT_FALSE(q.push(&v[3]));
EXPECT_EQ(q.get_and_reset_dropped_count(), 1u);
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky
}
TEST(LockFreeQueueNoAtomics, NullPushRejected) {
esphome::LockFreeQueue<int, 4> q;
EXPECT_FALSE(q.push(nullptr));
EXPECT_TRUE(q.empty());
}
TEST(LockFreeQueueNoAtomics, WrapAround) {
esphome::LockFreeQueue<int, 4> q;
int v[3] = {10, 20, 30};
// Cycle several times the ring size to cross the wrap boundary repeatedly.
for (int cycle = 0; cycle < 10; cycle++) {
for (auto &value : v)
ASSERT_TRUE(q.push(&value));
EXPECT_TRUE(q.full());
for (auto &value : v)
ASSERT_EQ(q.pop(), &value);
EXPECT_TRUE(q.empty());
}
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u);
}
TEST(LockFreeQueueNoAtomics, IncrementDroppedCount) {
esphome::LockFreeQueue<int, 4> q;
// Producer-side external drop accounting (pool exhausted before push).
q.increment_dropped_count();
q.increment_dropped_count();
EXPECT_EQ(q.get_and_reset_dropped_count(), 2u);
}
TEST(LockFreeQueueNoAtomics, InterleavedPushPop) {
esphome::LockFreeQueue<int, 8> q;
int v[64];
int popped = 0;
for (int i = 0; i < 64; i++) {
v[i] = i;
ASSERT_TRUE(q.push(&v[i]));
if (i % 2 == 1) {
int *first = q.pop();
ASSERT_NE(first, nullptr);
EXPECT_EQ(*first, popped++);
int *second = q.pop();
ASSERT_NE(second, nullptr);
EXPECT_EQ(*second, popped++);
}
}
EXPECT_TRUE(q.empty());
EXPECT_EQ(popped, 64);
}
} // namespace esphome::core::testing
+22
View File
@@ -0,0 +1,22 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: cst328_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: cst328_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: cst328
i2c_id: i2c_bus
id: cst328_touchscreen
display: cst328_ssd1306_i2c_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
binary_sensor:
- platform: cst328
id: touch_key_cst328
@@ -0,0 +1,8 @@
substitutions:
display_reset_pin: "4"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
cst328: !include common.yaml
+4
View File
@@ -1,14 +1,18 @@
one_wire:
- platform: gpio
id: ow_dallas_temp
pin: ${one_wire_pin}
sensor:
- platform: dallas_temp
one_wire_id: ow_dallas_temp
address: 0x1C0000031EDD2A28
name: Dallas Temperature 1
resolution: 9
- platform: dallas_temp
one_wire_id: ow_dallas_temp
name: Dallas Temperature 2
- platform: dallas_temp
one_wire_id: ow_dallas_temp
name: Dallas Temperature 3
index: 2
@@ -6,9 +6,15 @@ deep_sleep:
sleep_duration: 50s
wakeup_pin: ${wakeup_pin}
wakeup_pin_mode: INVERT_WAKEUP
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
esp32_ext1_wakeup:
pins:
- number: GPIO2
- pin: GPIO2
on_wake:
- logger.log: Woken by ext1 pin GPIO2
- number: GPIO13
mode: ANY_HIGH
touch_wakeup: true
@@ -5,8 +5,15 @@ deep_sleep:
sleep_duration: 50s
wakeup_pin: ${wakeup_pin}
wakeup_pin_mode: INVERT_WAKEUP
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
esp32_ext1_wakeup:
pins:
- number: GPIO2
- pin:
number: GPIO2
on_wake:
- logger.log: Woken by ext1 pin GPIO2
- number: GPIO5
mode: ANY_HIGH
@@ -5,3 +5,7 @@ deep_sleep:
sleep_duration: 50s
wakeup_pin: ${wakeup_pin}
wakeup_pin_mode: INVERT_WAKEUP
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
@@ -0,0 +1,19 @@
# Deep sleep combined with OTA while bootloader rollback support is enabled
# (the default on ESP-IDF). Entering deep sleep runs the safe shutdown hooks,
# where safe_mode confirms the running app image so the bootloader does not
# roll back a fresh OTA update when the device goes to sleep before
# boot_is_good_after has elapsed.
substitutions:
wakeup_pin: GPIO4
packages:
deep_sleep: !include common.yaml
deep_sleep_esp32: !include common-esp32.yaml
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
@@ -0,0 +1,16 @@
# Deep sleep combined with mcumgr OTA while MCUboot image rollback is enabled
# (the default on nRF52). Entering system-off deep sleep runs the safe
# shutdown hooks, where safe_mode confirms the running image so MCUboot does
# not revert a fresh OTA update on the next wake.
packages:
deep_sleep: !include common.yaml
deep_sleep:
run_duration: 10s
zephyr_ble_server:
ota:
- platform: zephyr_mcumgr
transport:
ble: true
@@ -1,6 +1,10 @@
deep_sleep:
run_duration: 30s
sleep_duration: 12h
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
wakeup_pin:
- pin:
number: P6
@@ -1,5 +1,9 @@
deep_sleep:
run_duration: 10s
sleep_duration: 50s
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
<<: !include common.yaml
+6 -5
View File
@@ -1,6 +1,7 @@
one_wire:
platform: ds2484
i2c_id: i2c_bus
address: 0x18
active_pullup: true
strong_pullup: false
- platform: ds2484
id: ow_ds2484
i2c_id: i2c_bus
address: 0x18
active_pullup: true
strong_pullup: false
+115
View File
@@ -0,0 +1,115 @@
# Combined DS248x test covering all chip variants and options:
# - DS2482-100: active pullup, multiple sensors + index access
# - DS2482-101: sleep pin, bus_sleep / hub_sleep
# - DS2482-800: all 8 channels
# - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection
ds248x:
- id: ds2482_100
address: 0x18
type: ds2482-100
active_pullup: true
- id: ds2482_101
address: 0x19
type: ds2482-101
active_pullup: true
sleep_pin:
number: GPIO12
inverted: false
bus_sleep: true
hub_sleep: true
- id: ds2482_800
address: 0x1a
type: ds2482-800
active_pullup: true
- id: ds2484_hub
address: 0x1b
type: ds2484
active_pullup: true
# DS2484-specific 1-Wire timing parameters (optional fine-tuning)
reset_low_time: 8 # tRSTL: Reset low time
master_sample_time: 8 # tMSP: Master sample point
write_0_low_time: 8 # tW0L: Write-0 low time
recovery_time: 8 # tREC0: Recovery time
active_pullup_resistance: 1000ohm # RWPU: weak pullup resistor selection
one_wire:
- platform: ds248x
ds248x_id: ds2482_100
channel: 0
id: ow_100
- platform: ds248x
ds248x_id: ds2482_101
channel: 0
id: ow_101
- platform: ds248x
ds248x_id: ds2482_800
channel: 0
id: ow_800_0
- platform: ds248x
ds248x_id: ds2482_800
channel: 1
id: ow_800_1
- platform: ds248x
ds248x_id: ds2482_800
channel: 2
id: ow_800_2
- platform: ds248x
ds248x_id: ds2482_800
channel: 3
id: ow_800_3
- platform: ds248x
ds248x_id: ds2482_800
channel: 4
id: ow_800_4
- platform: ds248x
ds248x_id: ds2482_800
channel: 5
id: ow_800_5
- platform: ds248x
ds248x_id: ds2482_800
channel: 6
id: ow_800_6
- platform: ds248x
ds248x_id: ds2482_800
channel: 7
id: ow_800_7
- platform: ds248x
ds248x_id: ds2484_hub
channel: 0
id: ow_2484
sensor:
# DS2482-100: explicit address + index-based access on the same bus
- platform: dallas_temp
one_wire_id: ow_100
address: 0x1c0000031edd2a28
name: Temp 100 by address
resolution: 12
- platform: dallas_temp
one_wire_id: ow_100
index: 0
name: Temp 100 by index
# DS2482-101 (sleep variant)
- platform: dallas_temp
one_wire_id: ow_101
address: 0x578295491f64ff28
name: Temp 101
# DS2482-800: sensors on a few of the eight channels
- platform: dallas_temp
one_wire_id: ow_800_0
address: 0x1c0000031edd2a28
name: Temp 800 CH0
- platform: dallas_temp
one_wire_id: ow_800_3
index: 0
name: Temp 800 CH3 by index
- platform: dallas_temp
one_wire_id: ow_800_7
address: 0x2800000123456789
name: Temp 800 CH7
# DS2484 (adjustable timing)
- platform: dallas_temp
one_wire_id: ow_2484
address: 0x1c0000031edd2a28
name: Temp 2484
resolution: 12
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
@@ -76,6 +76,14 @@ display:
- platform: epaper_spi
model: seeed-reterminal-e1002
- platform: epaper_spi
model: seeed-reterminal-e1004
cs_pin: 33
cs1_pin: 34
dc_pin: 35
reset_pin: 36
busy_pin: 37
enable_pin: 39
- platform: epaper_spi
model: seeed-ee04-mono-4.26
full_update_every: 10
@@ -204,6 +212,29 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Soldered Inkplate 6COLOR 7-color e-paper (600x448, UC8159-family)
- platform: epaper_spi
spi_id: spi_bus
model: inkplate6color
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0));
it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 165, 0));
# Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2)
- platform: epaper_spi
spi_id: spi_bus
@@ -0,0 +1,38 @@
esphome:
name: e1004-test
friendly_name: E1004 Test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
spi:
- id: epaper_spi_bus
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
spi_id: epaper_spi_bus
model: seeed-reterminal-e1004
update_interval: never
lambda: |-
it.fill(Color::WHITE);
it.rectangle(10, 10, it.get_width() - 20, it.get_height() - 20, Color::BLACK);
it.print(it.get_width() / 2, it.get_height() / 2, id(my_font), Color::BLACK, TextAlign::CENTER, "E1004 Test");
it.circle(100, 100, 30, Color(255, 0, 0));
it.circle(200, 100, 30, Color(0, 255, 0));
it.circle(300, 100, 30, Color(0, 0, 255));
it.circle(400, 100, 30, Color(255, 255, 0));
font:
- file: "gfonts://Roboto"
id: my_font
size: 20
logger:
@@ -0,0 +1,9 @@
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
<<: !include common.yaml
@@ -0,0 +1,22 @@
esphome:
project:
name: esphome.downgrade_test
version: "1.2.3"
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
enable_ota_downgrade_protection: true
signed_ota_verification:
signing_key: ../../components/esp32/dummy_signing_key.pem
signing_scheme: rsa3072
# wifi + ota so the IDF OTA backend compiles with USE_OTA_DOWNGRADE_PROTECTION.
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
@@ -21,7 +21,7 @@ esp32:
disable_fatfs: true
ota:
platform: esphome
- platform: esphome
wifi:
ssid: MySSID
@@ -0,0 +1,11 @@
# Secure Boot V2 schemes carry the public key inside each image's signature
# block, so verifying externally-signed binaries needs no key in the config:
# a bare block enables verification with the default rsa3072 scheme.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
<<: !include common.yaml
+2
View File
@@ -2,6 +2,7 @@ espnow:
id: espnow_component
auto_add_peer: false
channel: 1
max_payload_size: 1470
peers:
- 11:22:33:44:55:66
on_receive:
@@ -73,6 +74,7 @@ sensor:
id: espnow_temp_sensor
- platform: packet_transport
transport_id: transport1
provider: test-provider
remote_id: espnow_temp_sensor
id: remote_temp
+17
View File
@@ -0,0 +1,17 @@
image:
- platform: file
id: file_binary_image
file: ../../pnglogo.png
type: BINARY
dither: FloydSteinberg
- platform: file
id: file_rgb565_image
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
resize: 50x50
- platform: file
id: file_mdi_image
file: mdi:alert-circle-outline
type: BINARY
resize: 24x24
+14
View File
@@ -0,0 +1,14 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: ili9xxx
id: file_main_lcd
spi_id: spi_bus
model: ili9342
cs_pin: 15
dc_pin: 13
reset_pin: 21
invert_colors: true
<<: !include common.yaml
+9
View File
@@ -0,0 +1,9 @@
display:
- platform: sdl
id: file_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
<<: !include common.yaml
@@ -0,0 +1,22 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
xl9535:
id: expander
touchscreen:
# Firmware downloaded from the model's default release URL and cached.
- platform: gsl3670
model: seeed-reterminal-d1001
display: test_display_screen
interrupt_pin: 18
# Explicit firmware URL + SHA-256 override.
- platform: gsl3670
model: seeed-reterminal-d1001
display: test_display_screen
reset_pin: 10
interrupt_pin: 11
firmware:
url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin
sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4
+1 -12
View File
@@ -1,19 +1,8 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: gt911_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: gt911_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: gt911
i2c_id: i2c_bus
id: gt911_touchscreen
display: gt911_ssd1306_i2c_display
display: test_display_screen
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "12"
reset_pin: "13"
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
@@ -59,6 +59,24 @@ esphome:
id: test_regression_light
brightness: 100%
effect: "None"
- http_request.get:
url: https://esphome.io
capture_response: true
on_response:
then:
# Regression test: http_request.post with json: (dict variant) inside
# on_response of a capture_response: true request puts std::string&
# (body) into the nested action's Ts..., which exposes a
# const-correctness bug in HttpRequestSendAction::play() where
# encode_json_ receives const copies of non-const reference args.
- http_request.post:
url: https://esphome.io
json:
status: "ok"
# Same with json: lambda variant, exercises json_func_ path
- http_request.post:
url: https://esphome.io
json: !lambda "root[\"status\"] = \"ok\";"
http_request:
useragent: esphome/tagreader
+38 -19
View File
@@ -1,85 +1,104 @@
image:
- id: binary_image
- platform: file
id: binary_image
file: ../../pnglogo.png
type: BINARY
dither: FloydSteinberg
- id: transparent_transparent_image
- platform: file
id: transparent_transparent_image
file: ../../pnglogo.png
type: BINARY
transparency: chroma_key
- id: rgba_image
- platform: file
id: rgba_image
file: ../../pnglogo.png
type: RGB
transparency: alpha_channel
resize: 50x50
- id: rgb24_image
- platform: file
id: rgb24_image
file: ../../pnglogo.png
type: RGB
transparency: chroma_key
- id: rgb_image
- platform: file
id: rgb_image
file: ../../pnglogo.png
type: RGB
transparency: opaque
- id: rgb565_image
- platform: file
id: rgb565_image
file: ../../pnglogo.png
type: RGB565
transparency: opaque
- id: rgb565_ck_image
- platform: file
id: rgb565_ck_image
file: ../../pnglogo.png
type: RGB565
transparency: chroma_key
- id: rgb565_alpha_image
- platform: file
id: rgb565_alpha_image
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
- id: grayscale_alpha_image
- platform: file
id: grayscale_alpha_image
file: ../../pnglogo.png
type: grayscale
transparency: alpha_channel
resize: 50x50
- id: grayscale_ck_image
- platform: file
id: grayscale_ck_image
file: ../../pnglogo.png
type: grayscale
transparency: chroma_key
- id: grayscale_image
- platform: file
id: grayscale_image
file: ../../pnglogo.png
type: grayscale
transparency: opaque
- id: web_svg_image
- platform: file
id: web_svg_image
file: https://media.esphome.io/logo/logo.svg
resize: 256x48
type: BINARY
transparency: chroma_key
- id: web_tiff_image
- platform: file
id: web_tiff_image
file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff
type: RGB
resize: 48x48
- id: web_redirect_image
- platform: file
id: web_redirect_image
file: https://media.esphome.io/logo/logo.png
type: RGB
resize: 48x48
- id: mdi_alert
- platform: file
id: mdi_alert
type: BINARY
file: mdi:alert-circle-outline
resize: 50x50
- id: another_alert_icon
- platform: file
id: another_alert_icon
file: mdi:alert-outline
type: BINARY
- file: mdil:arrange-bring-to-front
- platform: file
file: mdil:arrange-bring-to-front
id: mdil_id
resize: 50x50
type: binary
transparency: chroma_key
- file: mdi:beer
- platform: file
file: mdi:beer
id: mdi_id
resize: 50x50
type: binary
transparency: chroma_key
- file: memory:alert-octagon
- platform: file
file: memory:alert-octagon
id: memory_id
resize: 50x50
type: binary
+3 -4
View File
@@ -12,12 +12,11 @@ display:
invert_colors: true
image:
defaults:
- platform: file
id: test_image
file: ../../pnglogo.png
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
images:
- id: test_image
file: ../../pnglogo.png
+57 -40
View File
@@ -7,43 +7,60 @@ display:
height: 480
image:
binary:
- id: binary_image
file: ../../pnglogo.png
dither: FloydSteinberg
- id: transparent_transparent_image
file: ../../pnglogo.png
transparency: chroma_key
rgb:
alpha_channel:
- id: rgba_image
file: ../../pnglogo.png
resize: 50x50
chroma_key:
- id: rgb24_image
file: ../../pnglogo.png
type: RGB
opaque:
- id: rgb_image
file: ../../pnglogo.png
rgb565:
- id: rgb565_image
file: ../../pnglogo.png
transparency: opaque
- id: rgb565_ck_image
file: ../../pnglogo.png
transparency: chroma_key
- id: rgb565_alpha_image
file: ../../pnglogo.png
transparency: alpha_channel
grayscale:
- id: grayscale_alpha_image
file: ../../pnglogo.png
transparency: alpha_channel
resize: 50x50
- id: grayscale_ck_image
file: ../../pnglogo.png
transparency: chroma_key
- id: grayscale_image
file: ../../pnglogo.png
transparency: opaque
- platform: file
id: binary_image
file: ../../pnglogo.png
type: binary
dither: FloydSteinberg
- platform: file
id: transparent_transparent_image
file: ../../pnglogo.png
type: binary
transparency: chroma_key
- platform: file
id: rgba_image
file: ../../pnglogo.png
type: rgb
transparency: alpha_channel
resize: 50x50
- platform: file
id: rgb24_image
file: ../../pnglogo.png
type: RGB
transparency: chroma_key
- platform: file
id: rgb_image
file: ../../pnglogo.png
type: rgb
transparency: opaque
- platform: file
id: rgb565_image
file: ../../pnglogo.png
type: rgb565
transparency: opaque
- platform: file
id: rgb565_ck_image
file: ../../pnglogo.png
type: rgb565
transparency: chroma_key
- platform: file
id: rgb565_alpha_image
file: ../../pnglogo.png
type: rgb565
transparency: alpha_channel
- platform: file
id: grayscale_alpha_image
file: ../../pnglogo.png
type: grayscale
transparency: alpha_channel
resize: 50x50
- platform: file
id: grayscale_ck_image
file: ../../pnglogo.png
type: grayscale
transparency: chroma_key
- platform: file
id: grayscale_image
file: ../../pnglogo.png
type: grayscale
transparency: opaque
@@ -0,0 +1,25 @@
# Legacy top-level `image:` defaults/images form (deprecated; migrates to
# `platform: file`). Config-only test exercising the deprecation/migration path,
# including the per-type byte_order drop when an entry overrides to a non-endian
# type (binary).
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
defaults:
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
images:
- id: legacy_defaults_image
file: ../../pnglogo.png
- id: legacy_defaults_binary
file: ../../pnglogo.png
type: binary
@@ -0,0 +1,24 @@
# Legacy top-level `image:` structured form using single-dict (non-list) values
# for `images:`, a type group, and a transparency group -- the old `ensure_list`
# accepted a bare dict in each of these places. Deprecated; migrates to
# `platform: file`. Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
images:
id: legacy_images_single_dict
file: ../../pnglogo.png
type: rgb565
rgb565:
id: legacy_grouped_type_single_dict
file: ../../pnglogo.png
rgb:
alpha_channel:
id: legacy_grouped_transparency_single_dict
file: ../../pnglogo.png
@@ -0,0 +1,25 @@
# Legacy top-level `image:` type-grouped form (deprecated; migrates to
# `platform: file`). Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
binary:
- id: legacy_grouped_binary
file: ../../pnglogo.png
rgb:
alpha_channel:
- id: legacy_grouped_rgba
file: ../../pnglogo.png
opaque:
- id: legacy_grouped_rgb
file: ../../pnglogo.png
rgb565:
- id: legacy_grouped_rgb565
file: ../../pnglogo.png
transparency: chroma_key
@@ -0,0 +1,16 @@
# Legacy top-level `image:` single-dict form (a bare image dict instead of a
# list; deprecated, migrates to `platform: file`). Config-only test exercising
# the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
id: legacy_single_image
file: ../../pnglogo.png
type: RGB565
transparency: opaque
+18
View File
@@ -0,0 +1,18 @@
# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`).
# Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- id: legacy_list_binary
file: ../../pnglogo.png
type: BINARY
- id: legacy_list_rgb565
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
@@ -0,0 +1,120 @@
#include <gtest/gtest.h>
#include "esphome/components/light/light_call.h"
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
namespace esphome::light::testing {
namespace {
// A light that only supports ON_OFF, like the `binary` platform and `status_led`.
class OnOffOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::ON_OFF});
return traits;
}
void write_state(LightState *state) override {}
};
// A dimmable light, like the `monochromatic` platform.
class BrightnessOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::BRIGHTNESS});
return traits;
}
void write_state(LightState *state) override {}
};
// validate_() is where zero brightness is resolved against the light's capabilities.
class TestableLightCall : public LightCall {
public:
using LightCall::LightCall;
using LightCall::validate_;
};
bool as_binary(const LightColorValues &values) {
bool binary;
values.as_binary(&binary);
return binary;
}
} // namespace
// An ON/OFF light has no "on but dark" state, so a zero brightness -- how effects encode
// their dark phase -- must turn the light off. Regression test for
// https://github.com/esphome/esphome/issues/17873.
TEST(LightCallOnOff, ZeroBrightnessTurnsOutputOff) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true).set_brightness(0.0f);
auto values = call.validate_();
EXPECT_FALSE(as_binary(values));
}
// The zero must not be stored, or no later turn-on could clear it: the capability check in
// validate_() drops any brightness an ON/OFF light doesn't support, so a stored zero would
// leave the light permanently off.
TEST(LightCallOnOff, ZeroBrightnessIsNotStored) {
OnOffOutput output;
LightState state(&output);
TestableLightCall dark_call(&state);
dark_call.set_state(true).set_brightness(0.0f);
state.remote_values = dark_call.validate_();
EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 1.0f);
// A plain turn-on afterwards must switch the light back on.
TestableLightCall on_call(&state);
on_call.set_state(true);
auto values = on_call.validate_();
EXPECT_TRUE(as_binary(values));
}
// A plain turn-on with no brightness must still light up.
TEST(LightCallOnOff, PlainTurnOnIsVisible) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true);
auto values = call.validate_();
EXPECT_TRUE(as_binary(values));
}
TEST(LightCallOnOff, TurnOffTurnsOutputOff) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(false);
auto values = call.validate_();
EXPECT_FALSE(as_binary(values));
}
// A dimmable light can represent "on but dark", so zero brightness must be kept as-is and
// must not be rewritten into a turn-off.
TEST(LightCallBrightness, ZeroBrightnessStaysOnButDark) {
BrightnessOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true).set_brightness(0.0f);
auto values = call.validate_();
EXPECT_TRUE(values.is_on());
EXPECT_FLOAT_EQ(values.get_brightness(), 0.0f);
}
} // namespace esphome::light::testing
+2
View File
@@ -0,0 +1,2 @@
ln882h_ble:
enable_on_boot: true
@@ -0,0 +1,2 @@
packages:
ln882h_ble: !include common.yaml
+2 -2
View File
@@ -91,8 +91,8 @@ binary_sensor:
animation: move_right
time: 600ms
- platform: lvgl
id: button_checker
name: LVGL button
id: common_button_checker
name: Common button
widget: spin_up
on_state:
then:
+138 -17
View File
@@ -24,20 +24,6 @@ binary_sensor:
name: Button A checked
widget: button_a
state: checked
- platform: lvgl
id: button_checker
name: LVGL button
widget: button_button
state: checked
on_state:
then:
- lvgl.checkbox.update:
id: checkbox_id
state:
checked: !lambda |-
auto y = x; // block inlining of one line return
return y;
- platform: lvgl
id: button_presser
name: Button pressed
@@ -49,6 +35,9 @@ lvgl:
rotation: 90
log_level: debug
resume_on_input: true
paused: true
update_when_display_idle: true
refresh_interval: 30ms
on_pause:
- logger.log: LVGL is Paused
- lvgl.display.set_rotation: 90
@@ -57,6 +46,40 @@ lvgl:
- lvgl.display.set_rotation:
rotation: 0
lvgl_id: lvgl_id
- lvgl.display.set_rotation:
rotation: !lambda "return 180;"
lvgl_id: lvgl_id
on_landscape:
- logger.log: LVGL display is now landscape
# Re-layout a container in response to orientation changes. The layout type
# and grid structure are fixed at creation; only the style options change.
- lvgl.widget.update:
id: grid_rows_only_shorthand
layout:
grid_column_align: center
grid_row_align: space_between
pad_row: 4px
- lvgl.update:
top_layer:
layout:
flex_flow: row
on_portrait:
- logger.log: LVGL display is now portrait
- lvgl.widget.update:
id: grid_rows_only_shorthand
layout:
grid_column_align: start
pad_row: 2px
# Top-level layout applies to the active screen
- lvgl.update:
layout:
flex_flow: column
pad_row: 8px
- lvgl.update:
top_layer:
layout:
flex_flow: column
flex_align_main: center
on_boot:
- logger.log: LVGL has started
@@ -64,6 +87,12 @@ lvgl:
id: meter_arc_indicator
start_value: 0
end_value: 180
- lvgl.animation.start:
id:
- anim_slide
- anim_color
duration: 3s
loop: true
on_invalidate_area:
logger.log: Invalidate area
on_resolution_change:
@@ -108,6 +137,52 @@ lvgl:
- obj:
bg_color: 0x000000
bg_opa: cover
top_layer:
widgets:
- obj:
id: anim_box
x: 0
y: 0
width: 50
height: 50
bg_color: 0xFF0000
- label:
id: anim_label
text: anim
animations:
- id: anim_slide
duration: 1s
start_delay: 100ms
auto_start: true
loop: true
timing: ease_in_out
on_start:
- logger.log: anim started
on_stop:
- logger.log: anim stopped
widgets:
- id: anim_box
x:
from: 0
to: 100
y:
from: 0
to: !lambda "return 80;"
opa:
from: 50%
to: 100%
- id: anim_color
duration: 2s
timing:
- round_trip
- type: gravity
bounce: 0.3
acceleration: 0.8
widgets:
- id: anim_label
text_color:
from: 0xFF0000
to: color_id
theme:
dark_mode: true
obj:
@@ -210,13 +285,37 @@ lvgl:
on_click:
then:
- lvgl.display.set_rotation: 0
- lvgl.animation.stop: anim_slide
- lvgl.animation.stop:
id:
- anim_slide
- anim_color
- lvgl.widget.hide: message_box
- lvgl.style.update:
id: style_test
bg_color: blue
bg_opa: !lambda return 0.5;
# `obj` is already themed above -- exercises updating an existing hidden style.
- lvgl.theme.update:
obj:
border_width: 2
# `label` is never mentioned under `theme:` -- exercises lazily creating the
# hidden style and getting it attached to already-built label widgets.
- lvgl.theme.update:
label:
text_color: red
# `button` is never mentioned under `theme:`, and only a non-default state is
# targeted here -- exercises that no spurious, empty main/default style is
# created (and attached to every button) alongside the requested one.
- lvgl.theme.update:
button:
pressed:
bg_color: red
- lvgl.image.update:
id: lv_image
src:
mapping: image_map
value: !lambda return round(1.0);
scale: !lambda return 512;
rotation: !lambda return 100;
pivot_x: !lambda return 20;
@@ -342,9 +441,16 @@ lvgl:
text_font: montserrat_40
border_post: true
on_press:
lvgl.label.update:
id: hello_label
text: Goodbye
- lvgl.label.update:
id: hello_label
text:
mapping: lvgl_string_map
value: !lambda return 2;
- lvgl.label.update:
id: hello_label
text:
mapping: lvgl_string_map
value: 2
on_click:
then:
- lvgl.animimg.stop: anim_img
@@ -1450,6 +1556,21 @@ image:
invert_alpha: true
transparency: alpha_channel
mapping:
- id: image_map
from: int
to: image
entries:
0: cat_image
1: dog_image
- id: lvgl_string_map
from: int
to: string
entries:
0: "First"
1: "Second"
2: "Third"
color:
- id: light_blue
hex: "3340FF"
+2 -3
View File
@@ -1,7 +1,8 @@
packages:
lvgl: !include lvgl-package.yaml
lvgl_package: !include lvgl-package.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
lvgl: !include common.yaml
sensor:
- platform: rotary_encoder
@@ -77,5 +78,3 @@ lvgl:
- component.update: tft_display
- delay: 60s
- lvgl.resume:
<<: !include common.yaml
+38 -1
View File
@@ -22,6 +22,36 @@ lvgl:
displays: sdl0
rotation: 180
top_layer:
widgets:
- obj:
id: anim_box
x: 0
y: 0
width: 40
height: 40
bg_color: 0xFF0000
animations:
- id: anim_slide
duration: 1s
start_delay: 100ms
auto_start: true
loop: true
timing:
- round_trip
- type: ease_in_out
weight: 3
on_start:
- logger.log: anim started
on_stop:
- logger.log: anim stopped
widgets:
- id: anim_box
x:
from: 0
to: !lambda "return 100;"
opa:
from: 50%
to: 100%
- id: lvgl_1
displays: sdl1
@@ -42,7 +72,14 @@ lvgl:
- label:
text: Click ME
on_click:
logger.log: Clicked
then:
- logger.log: Clicked
- lvgl.animation.stop:
id: anim_slide
lvgl_id: lvgl_0
- lvgl.animation.start:
id: anim_slide
lvgl_id: lvgl_0
font:
- file: "gfonts://Roboto"
+9
View File
@@ -0,0 +1,9 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# md5's to_code calls cg.add_define("USE_MD5"), which gates md5.h. C++ unit
# test builds that pull md5 in transitively (e.g. ota's host backend, which
# has an md5::MD5Digest member) need that define, otherwise md5.h compiles to
# nothing and the dependent headers fail to find md5::MD5Digest.
manifest.enable_codegen()
@@ -0,0 +1,7 @@
# No api, web_server or extra services so the fallback _http service
# (with version, mac and config_hash TXT records) is compiled.
wifi:
ssid: MySSID
password: password1
mdns:
@@ -0,0 +1,9 @@
# web_server without the native api so the version, mac and config_hash
# TXT records are attached to the web_server _http service.
wifi:
ssid: MySSID
password: password1
web_server:
mdns:
@@ -1,5 +1,5 @@
packages:
- !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
psram:
mode: octal
@@ -37,6 +37,9 @@ class MockUARTComponent : public uart::UARTComponent {
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif // defined(USE_ESP8266) || defined(USE_ESP32)
};
class TestableMitsubishiCN105 : public MitsubishiCN105 {
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include "esphome/components/uart/uart_component.h"
namespace esphome::modbus::testing {
// A UART that discards all writes, for tests that never inspect the wire.
class NullUART : public uart::UARTComponent {
public:
NullUART() { this->set_baud_rate(115200); }
void write_array(const uint8_t *data, size_t len) override {}
bool peek_byte(uint8_t *data) override { return false; }
bool read_array(uint8_t *data, size_t len) override { return false; }
size_t available() override { return 0; }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif
void check_logger_conflict() override {}
};
} // namespace esphome::modbus::testing
+223
View File
@@ -0,0 +1,223 @@
#include <gtest/gtest.h>
#include <atomic>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <span>
#include <vector>
#include "esphome/components/modbus/modbus.h"
// The allocation counters rely on AddressSanitizer's malloc hooks. The cpp_unit_test harness always
// builds with ASan, so this is exercised in CI; the fallback only applies to out-of-harness builds.
#ifndef __has_feature
#define __has_feature(x) 0
#endif
#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer)
#define HEAP_PROBE_HAS_ASAN
#endif
#ifdef HEAP_PROBE_HAS_ASAN
// Allocation counters fed by ASan's malloc hooks; sampled tightly around the calls under test.
static std::atomic<size_t> g_alloc_count{0};
static std::atomic<size_t> g_alloc_bytes{0};
static void malloc_hook(const volatile void *, size_t size) {
g_alloc_count++;
g_alloc_bytes += size;
}
static void free_hook(const volatile void *) {}
extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t),
void (*free_hook)(const volatile void *));
[[maybe_unused]] static const int g_hooks_installed = __sanitizer_install_malloc_and_free_hooks(malloc_hook, free_hook);
namespace esphome::modbus::testing {
namespace {
// A UART the test can inject received bytes into; sent bytes are discarded.
class InjectableUART : public uart::UARTComponent {
public:
void write_array(const uint8_t *data, size_t len) override {}
bool peek_byte(uint8_t *data) override {
if (this->rx_.empty())
return false;
*data = this->rx_.front();
return true;
}
bool read_array(uint8_t *data, size_t len) override {
if (len > this->rx_.size())
return false;
memcpy(data, this->rx_.data(), len);
this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len);
return true;
}
size_t available() override { return this->rx_.size(); }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
void check_logger_conflict() override {}
void inject_frame(uint8_t address, std::span<const uint8_t> pdu) {
// Wire frame: address + PDU + CRC16(low, high)
size_t start = this->rx_.size();
this->rx_.push_back(address);
this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end());
uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start);
this->rx_.push_back(crc & 0xFF);
this->rx_.push_back(crc >> 8);
}
private:
std::vector<uint8_t> rx_;
};
class NullDevice : public ModbusClientDevice {
public:
using ModbusClientDevice::ModbusClientDevice;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->responses++;
}
int responses{0};
};
struct Sample {
size_t count;
size_t bytes;
};
template<typename F> Sample sample(F &&f) {
size_t c0 = g_alloc_count.load(), b0 = g_alloc_bytes.load();
f();
return {g_alloc_count.load() - c0, g_alloc_bytes.load() - b0};
}
} // namespace
// Typical frames (reads and single-register/coil writes are exactly address + 5-byte PDU + CRC = 8
// bytes) fit the SmallInlineBuffer and are built with zero heap allocations; only larger frames spill
// to a single allocation.
TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) {
const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // 5 bytes -> 8-byte frame, inline
Sample typical = sample([&] {
ModbusFrame frame(0x02, read_pdu, sizeof(read_pdu));
(void) frame;
});
printf("HEAPPROBE frame_typical count=%zu bytes=%zu\n", typical.count, typical.bytes);
EXPECT_EQ(typical.count, 0u);
uint8_t large_pdu[250] = {0x10}; // multi-register write -> 253-byte frame, spills once
Sample large = sample([&] {
ModbusFrame frame(0x02, large_pdu, sizeof(large_pdu));
(void) frame;
});
printf("HEAPPROBE frame_large count=%zu bytes=%zu\n", large.count, large.bytes);
EXPECT_EQ(large.count, 1u);
}
// Queueing typical commands is allocation-free within the deque's first block: the frame fits the
// inline buffer, every entry is a plain append (ordering lives in selection, not storage), and the
// first block is already allocated when the hub is constructed. A 512-byte deque block holds
// 512 / sizeof(ModbusDeviceCommand) entries (16 on the 64-bit host); a deeper queue allocates more.
TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) {
ModbusClientHub hub;
ModbusClientDevice device(&hub, 0x02);
StaticVector<uint8_t, MAX_PDU_SIZE> req;
const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02};
req.assign(read_pdu, read_pdu + sizeof(read_pdu));
constexpr int n = 12;
static_assert(n * sizeof(ModbusDeviceCommand) < 512, "keep n within one deque block so the probe stays meaningful");
size_t total = 0;
for (int i = 0; i != n; i++) {
req[2] = static_cast<uint8_t>(i); // distinct start addresses: identical frames would dedup, not enqueue
total += sample([&] { device.send_pdu(req); }).count;
}
printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total);
EXPECT_EQ(total, 0u);
}
// A WRITE arriving behind queued reads is a plain append too - the old priority front-insert (and
// its possible front-block allocation) is gone; the write wins transmit SELECTION instead.
TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) {
ModbusClientHub hub;
ModbusClientDevice device(&hub, 0x02);
StaticVector<uint8_t, MAX_PDU_SIZE> req;
const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02};
req.assign(read_pdu, read_pdu + sizeof(read_pdu));
for (int i = 0; i != 3; i++) {
req[2] = static_cast<uint8_t>(i); // distinct start addresses: identical frames would dedup, not enqueue
device.send_pdu(req);
}
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
Sample append = sample([&] { device.send_pdu(write_pdu); });
printf("HEAPPROBE write_append count=%zu bytes=%zu\n", append.count, append.bytes);
EXPECT_EQ(append.count, 0u);
}
// End to end: bytes injected at the UART travel through receive, frame parsing, response matching and
// device dispatch. The first response may grow the hub's rx buffer once; after that warm-up, handling a
// response performs zero heap allocations all the way to the device callback.
TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) {
InjectableUART uart;
uart.set_baud_rate(115200); // tx timing math divides by the baud rate
ModbusClientHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // computes frame timing from the baud rate
NullDevice device(&hub, 0x02);
StaticVector<uint8_t, MAX_PDU_SIZE> req;
const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02};
req.assign(read_pdu, read_pdu + sizeof(read_pdu));
// Largest possible read response first, so the rx buffer warm-up covers every later size.
uint8_t large_resp[252] = {0x03, 250};
const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
auto round_trip = [&](std::span<const uint8_t> response_pdu) {
device.send_pdu(req);
hub.loop(); // transmit; the tx queue is empty during the measured receive below
uart.inject_frame(0x02, response_pdu);
return sample([&] { hub.loop(); }); // receive + parse + match + dispatch
};
Sample warmup = round_trip(std::span<const uint8_t>(large_resp, sizeof(large_resp)));
Sample steady_large = round_trip(std::span<const uint8_t>(large_resp, sizeof(large_resp)));
Sample steady_small = round_trip(small_resp);
printf("HEAPPROBE warmup count=%zu bytes=%zu\n", warmup.count, warmup.bytes);
printf("HEAPPROBE steady_large count=%zu bytes=%zu\n", steady_large.count, steady_large.bytes);
printf("HEAPPROBE steady_small count=%zu bytes=%zu\n", steady_small.count, steady_small.bytes);
EXPECT_EQ(device.responses, 3);
EXPECT_LE(warmup.count, 1u); // at most the one-time rx buffer growth
EXPECT_EQ(steady_large.count, 0u);
EXPECT_EQ(steady_small.count, 0u);
}
} // namespace esphome::modbus::testing
#else // !HEAP_PROBE_HAS_ASAN
// Stub every ASan-gated test name, so the suite's test list is identical in every build configuration.
namespace esphome::modbus::testing {
TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
TEST(HeapProbe, WriteBehindQueuedReadsAppendsAllocationFree) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
} // namespace esphome::modbus::testing
#endif // HEAP_PROBE_HAS_ASAN
@@ -0,0 +1,344 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Records every typed callback so tests can assert on the dispatch performed by the default
// on_response()/on_error() implementations.
class RecordingDevice : public ModbusClientDevice {
public:
struct ReadRegistersCall {
uint16_t start_address;
std::vector<uint16_t> registers;
ResponseStatus status;
};
struct ReadBitsCall {
uint16_t start_address;
uint16_t count;
std::vector<uint8_t> packed;
ResponseStatus status;
};
struct WriteCall {
uint16_t address;
uint16_t value;
ResponseStatus status;
};
void on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->holding_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
}
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->input_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
}
void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->coil_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
}
void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->discrete_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
}
void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override {
this->write_single_register_calls.push_back({address, value, status});
}
void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) override {
this->write_single_coil_calls.push_back({address, static_cast<uint16_t>(value), status});
}
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->write_multiple_registers_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
}
void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->write_multiple_coils_calls.push_back(
{start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
}
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) override {
this->custom_requests.emplace_back(request_pdu.begin(), request_pdu.end());
this->custom_responses.emplace_back(response_pdu.begin(), response_pdu.end());
this->custom_statuses.push_back(status);
}
std::vector<ReadRegistersCall> holding_calls;
std::vector<ReadRegistersCall> input_calls;
std::vector<ReadBitsCall> coil_calls;
std::vector<ReadBitsCall> discrete_calls;
std::vector<WriteCall> write_single_register_calls;
std::vector<WriteCall> write_single_coil_calls;
std::vector<ReadRegistersCall> write_multiple_registers_calls;
std::vector<ReadBitsCall> write_multiple_coils_calls;
std::vector<std::vector<uint8_t>> custom_requests;
std::vector<std::vector<uint8_t>> custom_responses;
std::vector<ResponseStatus> custom_statuses;
};
// Overrides only the generic callbacks to verify the typed defaults delegate to them.
class GenericDevice : public ModbusClientDevice {
public:
void on_read_registers(EntityType register_type, uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->register_type = register_type;
this->start_address = start_address;
this->registers.assign(registers.begin(), registers.end());
this->calls++;
}
void on_read_bits(EntityType register_type, uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->register_type = register_type;
this->start_address = start_address;
this->bit_count = bits.size();
this->calls++;
}
EntityType register_type{EntityType::CUSTOM};
uint16_t start_address{0};
uint16_t bit_count{0};
std::vector<uint16_t> registers;
int calls{0};
};
} // namespace
TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 regs at 0x100
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; // 0x002A, 0x0100
device.on_response(request, response);
ASSERT_EQ(device.holding_calls.size(), 1u);
const auto &call = device.holding_calls.front();
EXPECT_EQ(call.start_address, 0x100);
EXPECT_EQ(call.registers, (std::vector<uint16_t>{0x002A, 0x0100}));
EXPECT_FALSE(call.status.has_value());
}
TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) {
GenericDevice device;
const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01};
const uint8_t response[] = {0x04, 0x02, 0x12, 0x34};
device.on_response(request, response);
EXPECT_EQ(device.calls, 1);
EXPECT_EQ(device.register_type, EntityType::INPUT_REGISTER);
EXPECT_EQ(device.start_address, 0x10);
EXPECT_EQ(device.registers, (std::vector<uint16_t>{0x1234}));
}
TEST(ModbusClientDeviceFanOut, ReadDiscreteInputsDelegateToGenericBits) {
GenericDevice device;
const uint8_t request[] = {0x02, 0x00, 0x20, 0x00, 0x05}; // 5 inputs at 0x20
const uint8_t response[] = {0x02, 0x01, 0x15};
device.on_response(request, response);
EXPECT_EQ(device.calls, 1);
EXPECT_EQ(device.register_type, EntityType::DISCRETE_INPUT);
EXPECT_EQ(device.start_address, 0x20);
EXPECT_EQ(device.bit_count, 5);
}
// A CRC-valid response whose length does not match its request cannot be decoded per the
// function-code contract: it goes to the catch-all with the raw PDUs, not to the typed callback.
TEST(ModbusClientDeviceFanOut, ReadRegistersMismatchedLengthGoesToCatchAll) {
RecordingDevice device;
// Request asks for 4 registers but the response only carries 1.
const uint8_t request[] = {0x03, 0x00, 0x00, 0x00, 0x04};
const uint8_t response[] = {0x03, 0x02, 0xBE, 0xEF};
device.on_response(request, response);
EXPECT_TRUE(device.holding_calls.empty());
ASSERT_EQ(device.custom_responses.size(), 1u);
EXPECT_EQ(device.custom_responses.front(), (std::vector<uint8_t>(response, response + sizeof(response))));
}
// Coil responses are validated the same way: byte count must be ceil(count / 8).
TEST(ModbusClientDeviceFanOut, ReadCoilsMismatchedLengthGoesToCatchAll) {
RecordingDevice device;
const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils -> 3 packed bytes
const uint8_t response[] = {0x01, 0x02, 0xCD, 0x6B}; // only 2
device.on_response(request, response);
EXPECT_TRUE(device.coil_calls.empty());
EXPECT_EQ(device.custom_responses.size(), 1u);
}
TEST(ModbusClientDeviceFanOut, ReadCoilsSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils at 0x13
const uint8_t response[] = {0x01, 0x03, 0xCD, 0x6B, 0x05};
device.on_response(request, response);
ASSERT_EQ(device.coil_calls.size(), 1u);
const auto &call = device.coil_calls.front();
EXPECT_EQ(call.start_address, 0x13);
EXPECT_EQ(call.count, 19);
EXPECT_EQ(call.packed, (std::vector<uint8_t>{0xCD, 0x6B, 0x05}));
EXPECT_FALSE(call.status.has_value());
// first coil = bit 0 of byte 0
EXPECT_TRUE(helpers::bit_from_packed(0, call.packed));
EXPECT_FALSE(helpers::bit_from_packed(1, call.packed));
}
TEST(ModbusClientDeviceFanOut, WriteSingleRegisterSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03};
device.on_response(request, request); // echo
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
const auto &call = device.write_single_register_calls.front();
EXPECT_EQ(call.address, 1);
EXPECT_EQ(call.value, 3);
EXPECT_FALSE(call.status.has_value());
}
TEST(ModbusClientDeviceFanOut, WriteSingleCoilSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x05, 0x00, 0xAC, 0xFF, 0x00};
device.on_response(request, request);
ASSERT_EQ(device.write_single_coil_calls.size(), 1u);
EXPECT_EQ(device.write_single_coil_calls.front().address, 0xAC);
EXPECT_EQ(device.write_single_coil_calls.front().value, 1u);
}
TEST(ModbusClientDeviceFanOut, WriteErrorReportsRequestArgumentsAndStatus) {
RecordingDevice device;
const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03};
const uint8_t exception[] = {0x86, 0x02}; // ILLEGAL_DATA_ADDRESS
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
const auto &call = device.write_single_register_calls.front();
EXPECT_EQ(call.address, 1);
EXPECT_EQ(call.value, 3);
EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
TEST(ModbusClientDeviceFanOut, ReadErrorReportsEmptyDataAndStatus) {
RecordingDevice device;
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t exception[] = {0x83, 0x02};
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
ASSERT_EQ(device.holding_calls.size(), 1u);
const auto &call = device.holding_calls.front();
EXPECT_EQ(call.start_address, 0x100);
EXPECT_TRUE(call.registers.empty());
EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
TEST(ModbusClientDeviceFanOut, CustomFunctionCodeGoesToCatchAll) {
RecordingDevice device;
const uint8_t request[] = {0x47, 0x01, 0x02, 0x03, 0x04};
const uint8_t response[] = {0x47, 0xAA, 0xBB};
device.on_response(request, response);
ASSERT_EQ(device.custom_requests.size(), 1u);
EXPECT_EQ(device.custom_requests.front(), (std::vector<uint8_t>{0x47, 0x01, 0x02, 0x03, 0x04}));
EXPECT_EQ(device.custom_responses.front(), (std::vector<uint8_t>{0x47, 0xAA, 0xBB}));
EXPECT_FALSE(device.custom_statuses.front().has_value());
EXPECT_TRUE(device.holding_calls.empty());
// On failure the catch-all receives an empty response and the status (the exception code).
const uint8_t exception[] = {0xC7, 0x02};
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
ASSERT_EQ(device.custom_statuses.size(), 2u);
EXPECT_EQ(device.custom_statuses.back(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_TRUE(device.custom_responses.back().empty());
}
// A write ack only echoes the start address and count, so the data that was written is decoded from the
// request PDU: [0] function code, [1..2] start address, [3..4] count, [5] byte count, [6..] data.
TEST(ModbusClientDeviceFanOut, WriteMultipleAcksReportStartAndData) {
RecordingDevice device;
// Write 2 registers (0x0001, 0x0002) at 0x0020: byte count 4, data from offset 6.
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02};
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
device.on_response(reg_request, reg_ack);
// Write 10 coils at 0x0030: byte count 2, packed bits 0xFF 0x03 from offset 6.
const uint8_t coil_request[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03};
const uint8_t coil_ack[] = {0x0F, 0x00, 0x30, 0x00, 0x0A};
device.on_response(coil_request, coil_ack);
ASSERT_EQ(device.write_multiple_registers_calls.size(), 1u);
EXPECT_EQ(device.write_multiple_registers_calls.front().start_address, 0x20);
EXPECT_EQ(device.write_multiple_registers_calls.front().registers, (std::vector<uint16_t>{0x0001, 0x0002}));
ASSERT_EQ(device.write_multiple_coils_calls.size(), 1u);
EXPECT_EQ(device.write_multiple_coils_calls.front().start_address, 0x30);
EXPECT_EQ(device.write_multiple_coils_calls.front().count, 10);
EXPECT_EQ(device.write_multiple_coils_calls.front().packed, (std::vector<uint8_t>{0xFF, 0x03}));
}
// A truncated request (byte-count header promises more data than the PDU carries) is not a standard
// write-multiple, so it is diverted to on_custom_response() - never clamped and delivered as if complete.
TEST(ModbusClientDeviceFanOut, WriteMultipleTruncatedRequestDispatchesAsCustom) {
RecordingDevice device;
// Header claims 2 registers / 4 data bytes, but only one register's worth is present.
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01};
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
device.on_response(reg_request, reg_ack);
EXPECT_TRUE(device.write_multiple_registers_calls.empty());
ASSERT_EQ(device.custom_requests.size(), 1u);
EXPECT_EQ(device.custom_requests.front(), (std::vector<uint8_t>{0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}));
}
// A request whose byte-count header disagrees with its own quantity field (here: 2 registers but a
// byte count of 2 instead of 4, with matching data) is non-standard and diverted to the catch-all.
TEST(ModbusClientDeviceFanOut, WriteMultipleInconsistentByteCountDispatchesAsCustom) {
RecordingDevice device;
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01};
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
device.on_response(reg_request, reg_ack);
EXPECT_TRUE(device.write_multiple_registers_calls.empty());
EXPECT_EQ(device.custom_requests.size(), 1u);
}
// An exception on a read still dispatches to the typed callback (empty data, status set): the gate must
// not require a standard response on the failure path, because on_error() delivers an empty response by
// design.
TEST(ModbusClientDeviceFanOut, ReadErrorWithEmptyResponseStillDispatchesTyped) {
RecordingDevice device;
const uint8_t read_request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
device.on_error(read_request, ExceptionCode::ILLEGAL_DATA_ADDRESS);
ASSERT_EQ(device.holding_calls.size(), 1u);
EXPECT_TRUE(device.holding_calls.front().registers.empty());
EXPECT_EQ(device.holding_calls.front().status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_TRUE(device.custom_requests.empty());
}
// An error on a coil read must deliver a PackedBits view whose size() is zero - the count must never
// promise bits that have no bytes behind them (operator[] is unchecked).
TEST(ModbusClientDeviceFanOut, ReadCoilsErrorDeliversZeroCountBits) {
RecordingDevice device;
const uint8_t read_request[] = {0x01, 0x01, 0x00, 0x00, 0x0A};
device.on_error(read_request, ExceptionCode::SERVICE_DEVICE_FAILURE);
ASSERT_EQ(device.coil_calls.size(), 1u);
EXPECT_EQ(device.coil_calls.front().count, 0);
EXPECT_TRUE(device.coil_calls.front().packed.empty());
}
// Single-write acks: on success the delivered value is the device's echo (real read-back);
// on an exception it falls back to the request copy.
TEST(ModbusTypedDispatch, SingleWriteAckPrefersTheResponseEcho) {
RecordingDevice device;
const uint8_t request[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
const uint8_t echo_clamped[] = {0x06, 0x00, 0x10, 0x00, 0x28}; // device clamped 42 -> 40
device.on_response(request, echo_clamped);
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
EXPECT_EQ(device.write_single_register_calls.front().value, 0x0028); // the echo, not the request
device.on_error(request, ExceptionCode::ILLEGAL_DATA_VALUE);
ASSERT_EQ(device.write_single_register_calls.size(), 2u);
EXPECT_EQ(device.write_single_register_calls.back().value, 0x002A); // exception: request copy
}
} // namespace esphome::modbus::testing
File diff suppressed because it is too large Load Diff
+338 -8
View File
@@ -1,10 +1,12 @@
#include <gtest/gtest.h>
#include <memory>
#include "esphome/components/modbus/modbus_helpers.h"
namespace esphome::modbus::helpers {
using FC = ModbusFunctionCode;
using FC = FunctionCode;
// --- server_frame_length ---------------------------------------------------
// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from
@@ -83,6 +85,13 @@ TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) {
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2);
}
TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) {
// FC 0x17's write byte count caps at the spec 6.17 limit of 121 registers (242 bytes), deliberately
// tighter than FC 0x10's 123, so a corrupt byte count cannot make the parser wait past the real frame.
const uint8_t pdu[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xFF}; // claims 255 bytes
EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2);
}
TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) {
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9);
@@ -97,6 +106,124 @@ TEST(ModbusClientFrameLength, MiscFixedAndUnknown) {
EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- file-record length cap --------------------------------------------------
// FC 0x14/0x15 are parsed only to keep the frame parser in sync; the byte count caps at 251
// (MAX_PDU_SIZE - 2), reproducing the released frame-relative bound of MAX_FRAME_SIZE - 5.
TEST(ModbusFileRecordCap, PduLengthCapsByteCountAt251) {
const uint8_t pdu[] = {static_cast<uint8_t>(FC::READ_FILE_RECORD), 0xFF}; // claims 255 bytes
EXPECT_EQ(server_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2));
EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2));
// Frame wrappers: address(1) + PDU + CRC(2) stays within the RTU 256-byte frame limit.
const uint8_t frame[] = {0x01, static_cast<uint8_t>(FC::WRITE_FILE_RECORD), 0xFF};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE);
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE);
}
TEST(ModbusFileRecordCap, StandardChecksAcceptUpTo251) {
// A full-length PDU at the cap: function(1) + byte count(1) + 251 data bytes = MAX_PDU_SIZE.
std::vector<uint8_t> at_cap(MAX_PDU_SIZE, 0x00);
at_cap[0] = static_cast<uint8_t>(FC::READ_FILE_RECORD);
at_cap[1] = MAX_PDU_SIZE - 2;
EXPECT_TRUE(is_server_pdu_standard(at_cap.data(), at_cap.size()));
EXPECT_TRUE(is_client_pdu_standard(at_cap.data(), at_cap.size()));
// Byte count 252 in the same 253-byte buffer: the parsed length still matches (capped), so this
// exercises the byte-count bound itself rather than the length identity.
at_cap[1] = MAX_PDU_SIZE - 1;
EXPECT_FALSE(is_server_pdu_standard(at_cap.data(), at_cap.size()));
EXPECT_FALSE(is_client_pdu_standard(at_cap.data(), at_cap.size()));
}
// --- is_client_pdu_standard / is_server_pdu_standard -------------------------
// The gatekeepers for the typed client dispatch: a PDU must be exactly its function code's standard
// shape, with byte count, quantity, and address range all consistent.
TEST(ModbusPduStandard, ClientReadAndWriteConformant) {
const uint8_t read_regs[] = {0x03, 0x01, 0x00, 0x00, 0x02};
EXPECT_TRUE(is_client_pdu_standard(read_regs, sizeof(read_regs)));
const uint8_t write_regs[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02};
EXPECT_TRUE(is_client_pdu_standard(write_regs, sizeof(write_regs)));
// 10 coils pack into 2 data bytes - the coil formula, not the register one.
const uint8_t write_coils[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03};
EXPECT_TRUE(is_client_pdu_standard(write_coils, sizeof(write_coils)));
}
TEST(ModbusPduStandard, ClientRejectsNonConformant) {
// Truncated: header claims 4 data bytes, only 2 present.
const uint8_t truncated[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01};
EXPECT_FALSE(is_client_pdu_standard(truncated, sizeof(truncated)));
// Byte count disagrees with quantity (2 registers need 4 bytes, header says 2).
const uint8_t inconsistent[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01};
EXPECT_FALSE(is_client_pdu_standard(inconsistent, sizeof(inconsistent)));
// Coil write using the register byte-count formula (10 coils with 20 data bytes).
const uint8_t coil_as_regs[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x14, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
EXPECT_FALSE(is_client_pdu_standard(coil_as_regs, sizeof(coil_as_regs)));
// Quantity zero and quantity beyond the per-function-code maximum.
const uint8_t zero_qty[] = {0x03, 0x01, 0x00, 0x00, 0x00};
EXPECT_FALSE(is_client_pdu_standard(zero_qty, sizeof(zero_qty)));
const uint8_t too_many[] = {0x03, 0x01, 0x00, 0x00, 0x7E}; // 126 > 125
EXPECT_FALSE(is_client_pdu_standard(too_many, sizeof(too_many)));
// Address range overflow: 0xFFFF + 2 registers exceeds the 16-bit register space.
const uint8_t wraps[] = {0x03, 0xFF, 0xFF, 0x00, 0x02};
EXPECT_FALSE(is_client_pdu_standard(wraps, sizeof(wraps)));
}
TEST(ModbusPduStandard, ServerReadResponses) {
const uint8_t ok[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
EXPECT_TRUE(is_server_pdu_standard(ok, sizeof(ok)));
// Byte-count header disagrees with the actual length.
const uint8_t lying[] = {0x03, 0x06, 0x00, 0x2A, 0x01, 0x00};
EXPECT_FALSE(is_server_pdu_standard(lying, sizeof(lying)));
// An empty PDU (the on_error path) is not a standard response.
EXPECT_FALSE(is_server_pdu_standard(ok, 0));
}
TEST(ModbusPduStandard, ServerResponsesRejectDegenerateShapes) {
// A read response always carries data: byte count zero is non-conformant.
const uint8_t zero_bc[] = {0x03, 0x00};
EXPECT_FALSE(is_server_pdu_standard(zero_bc, sizeof(zero_bc)));
// Registers are 2 bytes each: an odd byte count would silently truncate a register.
const uint8_t odd_bc[] = {0x03, 0x03, 0x00, 0x01, 0x02};
EXPECT_FALSE(is_server_pdu_standard(odd_bc, sizeof(odd_bc)));
// Bit reads have no parity requirement: one packed byte is a fine coil response.
const uint8_t coil_one_byte[] = {0x01, 0x01, 0x05};
EXPECT_TRUE(is_server_pdu_standard(coil_one_byte, sizeof(coil_one_byte)));
// A write-multiple echo claiming 65535 registers written is bounded like the request side.
const uint8_t wild_echo[] = {0x10, 0x00, 0x00, 0xFF, 0xFF};
EXPECT_FALSE(is_server_pdu_standard(wild_echo, sizeof(wild_echo)));
const uint8_t ok_echo[] = {0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_TRUE(is_server_pdu_standard(ok_echo, sizeof(ok_echo)));
}
TEST(ModbusPduStandard, SingleCoilValueMustBeCanonical) {
// FC 0x05's value field allows exactly 0xFF00 (ON) and 0x0000 (OFF); anything else is non-standard.
const uint8_t on[] = {0x05, 0x00, 0x10, 0xFF, 0x00};
const uint8_t off[] = {0x05, 0x00, 0x10, 0x00, 0x00};
const uint8_t junk[] = {0x05, 0x00, 0x10, 0x12, 0x34};
EXPECT_TRUE(is_client_pdu_standard(on, sizeof(on)));
EXPECT_TRUE(is_client_pdu_standard(off, sizeof(off)));
EXPECT_FALSE(is_client_pdu_standard(junk, sizeof(junk)));
EXPECT_TRUE(is_server_pdu_standard(on, sizeof(on))); // the response echoes the request
EXPECT_FALSE(is_server_pdu_standard(junk, sizeof(junk)));
}
TEST(ModbusPduStandard, NonStandardFunctionCodesAcceptedOnLengthAlone) {
// Custom, unimplemented, and exception function codes have no standard shape to check: they are
// accepted whenever the parsed length matches, so a dispatcher can still route them by function
// code instead of having them rejected outright. This is the documented contract - see the header.
const uint8_t custom[] = {0x42}; // user-defined space; 1 byte matches the MIN_PDU_SIZE fallback
EXPECT_TRUE(is_client_pdu_standard(custom, sizeof(custom)));
EXPECT_TRUE(is_server_pdu_standard(custom, sizeof(custom)));
const uint8_t unimplemented[] = {0x07}; // READ_EXCEPTION_STATUS
EXPECT_TRUE(is_server_pdu_standard(unimplemented, sizeof(unimplemented)));
const uint8_t exception[] = {0x83, 0x02}; // exception response; length pinned to 2 bytes
EXPECT_TRUE(is_server_pdu_standard(exception, sizeof(exception)));
// The length identity still gates: extra bytes beyond the parsed fallback are non-conformant.
const uint8_t custom_long[] = {0x42, 0x01};
EXPECT_FALSE(is_client_pdu_standard(custom_long, sizeof(custom_long)));
}
// --- create_client_pdu -----------------------------------------------------
// PDU = function code + data (no address, no CRC).
@@ -179,19 +306,44 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
EXPECT_TRUE(pdu.empty());
}
// The generic write path requires the data length to agree exactly with the entity count
// (registers: 2 bytes each; coils: 8 packed per byte) - the same rule the response dispatch
// enforces via is_client_pdu_standard(), so a frame built here always passes that gate.
TEST(ModbusCreateClientPdu, WriteMultipleRejectsMismatchedDataLength) {
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
// 2 registers need exactly 4 data bytes.
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 3).empty());
EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 4).empty());
// 10 coils pack into exactly 2 data bytes - the coil formula, not the register one.
EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2).empty());
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 4).empty());
}
TEST(ModbusCreateClientPdu, WriteCoilsUseTheCoilLimitNotTheRegisterLimit) {
// 200 coils: above the 123-register write limit but well within the 1968-coil limit; 25 data bytes.
std::vector<uint8_t> values(25, 0xAA);
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 200, values.data(), values.size());
ASSERT_FALSE(pdu.empty());
EXPECT_EQ(pdu[5], 25); // byte count uses the coil formula
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); // builder output passes the validator
// Builder and validator agree at the top of the range too: 1969 coils rejected.
std::vector<uint8_t> big((1969 + 7) / 8, 0x00);
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 1969, big.data(), big.size()).empty());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0);
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) {
const std::vector<uint8_t> data{0x12, 0x34, 0x56};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0);
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value());
}
TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
EXPECT_EQ(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
}
// --- registers_to_number ---------------------------------------------------
@@ -218,16 +370,194 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
const uint16_t registers[] = {0x8001, 0x0002};
const std::vector<uint8_t> bytes{0x80, 0x01, 0x00, 0x02};
for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) {
EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF))
EXPECT_EQ(registers_to_number(registers, 2, value_type),
payload_to_number(std::span<const uint8_t>(bytes), value_type, 0, 0xFFFFFFFF))
<< "value_type=" << static_cast<int>(value_type);
}
}
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
const uint16_t registers[] = {0x1234};
bool error = false;
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0);
EXPECT_TRUE(error);
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
}
// --- typed builders ----------------------------------------------------------
TEST(ModbusTypedBuilders, ReadPduWireBytes) {
auto pdu = create_read_pdu(FC::READ_HOLDING_REGISTERS, 0x0102, 3);
const std::vector<uint8_t> expected{0x03, 0x01, 0x02, 0x00, 0x03};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
// Reads that run past the 16-bit address space are refused.
EXPECT_TRUE(create_read_pdu(FC::READ_HOLDING_REGISTERS, 0xFFFF, 2).empty());
}
TEST(ModbusTypedBuilders, WriteSinglePduWireBytes) {
auto reg = create_write_single_register_pdu(0x0010, 0xABCD);
const std::vector<uint8_t> expected_reg{0x06, 0x00, 0x10, 0xAB, 0xCD};
EXPECT_EQ(std::vector<uint8_t>(reg.begin(), reg.end()), expected_reg);
EXPECT_TRUE(is_client_pdu_standard(reg.data(), reg.size()));
auto coil_on = create_write_single_coil_pdu(0x0011, true);
auto coil_off = create_write_single_coil_pdu(0x0011, false);
const std::vector<uint8_t> expected_on{0x05, 0x00, 0x11, 0xFF, 0x00};
const std::vector<uint8_t> expected_off{0x05, 0x00, 0x11, 0x00, 0x00};
EXPECT_EQ(std::vector<uint8_t>(coil_on.begin(), coil_on.end()), expected_on);
EXPECT_EQ(std::vector<uint8_t>(coil_off.begin(), coil_off.end()), expected_off);
EXPECT_TRUE(is_client_pdu_standard(coil_on.data(), coil_on.size()));
EXPECT_TRUE(is_client_pdu_standard(coil_off.data(), coil_off.size()));
}
TEST(ModbusTypedBuilders, WriteRegistersPduWireBytes) {
const uint16_t values[] = {0x000B, 0x0016};
auto pdu = create_write_registers_pdu(0x0000, values);
const std::vector<uint8_t> expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
// Writes that run past the 16-bit address space are refused.
EXPECT_TRUE(create_write_registers_pdu(0xFFFF, values).empty());
}
TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
std::vector<uint16_t> values(MAX_NUM_OF_REGISTERS_TO_WRITE + 1, 0xAAAA);
EXPECT_TRUE(create_write_registers_pdu(0x0000, values).empty());
values.pop_back();
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
}
TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) {
// The container overload appends - the semantic every migrated caller relies on when a lambda
// has already put words into the buffer.
std::vector<uint16_t> data{0x1234};
float_to_payload(data, 1.0f, SensorValueType::U_WORD);
ASSERT_EQ(data.size(), 2u);
EXPECT_EQ(data[0], 0x1234);
EXPECT_EQ(data[1], 0x0001);
}
TEST(ModbusCreateClientPdu, ExceptionFlaggedWriteCodesRejected) {
// is_function_code_write() masks the exception bit; the builder must not.
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
EXPECT_TRUE(create_client_pdu(FunctionCode(0x90), 0x0000, 2, values, 4).empty());
EXPECT_TRUE(create_client_pdu(FunctionCode(0x85), 0x0000, 1, values, 2).empty());
}
TEST(ModbusTypedBuilders, BoolSpanCoilBuilderRejectsOverLimit) {
// This early guard is what keeps the 246-byte packing buffer from overflowing - the shared core's
// identical check runs after packing, so it cannot protect it.
auto big = std::make_unique<bool[]>(MAX_NUM_OF_COILS_TO_WRITE + 1);
EXPECT_TRUE(create_write_coils_pdu(0, std::span<const bool>(big.get(), MAX_NUM_OF_COILS_TO_WRITE + 1)).empty());
}
TEST(ModbusCreateClientPdu, GenericCoilWriteMasksTrailingPadBits) {
// 10 coils with junk in the pad bits of the last data byte: the generic path masks them like the
// typed builder, so both produce identical wire bytes.
const uint8_t values[] = {0xFF, 0xFF};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2);
ASSERT_FALSE(pdu.empty());
EXPECT_EQ(pdu[pdu.size() - 1], 0x03); // bits 8-9 kept, pad bits 10-15 zeroed
}
TEST(ModbusCreateClientPdu, SingleCoilValueValidated) {
const uint8_t on[] = {0xFF, 0x00};
const uint8_t junk[] = {0x01, 0x00};
EXPECT_FALSE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, on, 2).empty());
EXPECT_TRUE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, junk, 2).empty());
}
// --- create_write_coils_pdu (packed) ---------------------------------------
TEST(ModbusWriteCoilsPacked, MatchesBoolBuilder) {
const bool coils[] = {true, false, true, true, false, false, true, false, true, true};
uint8_t packed[] = {0b01001101, 0b00000011};
auto from_bools = create_write_coils_pdu(0x13, coils);
auto from_packed = create_write_coils_pdu(0x13, PackedBits(packed, 10));
ASSERT_EQ(from_packed.size(), from_bools.size());
EXPECT_EQ(0, memcmp(from_packed.data(), from_bools.data(), from_bools.size()));
}
TEST(ModbusWriteCoilsPacked, MasksUnusedTrailingBits) {
uint8_t packed[] = {0xFF};
auto pdu = create_write_coils_pdu(0, PackedBits(packed, 3));
ASSERT_EQ(pdu.size(), 7u);
EXPECT_EQ(pdu[6], 0x07);
}
TEST(ModbusWriteCoilsPacked, RejectsShortBufferAndZeroCount) {
uint8_t packed[] = {0xFF};
EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 9)).empty()); // needs 2 bytes
EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 0)).empty());
}
TEST(ModbusHelpersTest, PackedBitsReadsLsbFirst) {
const uint8_t packed[] = {0x0D, 0x03}; // bits 0,2,3 and 8,9
PackedBits bits(packed, 11);
EXPECT_EQ(bits.size(), 11u);
EXPECT_TRUE(bits[0]);
EXPECT_FALSE(bits[1]);
EXPECT_TRUE(bits[2]);
EXPECT_TRUE(bits[3]);
EXPECT_FALSE(bits[7]);
EXPECT_TRUE(bits[8]);
EXPECT_TRUE(bits[9]);
EXPECT_FALSE(bits[10]);
EXPECT_EQ(bits.bytes().size(), 2u);
}
TEST(ModbusHelpersTest, MutablePackedBitsSetsAndClears) {
uint8_t packed[2] = {0x00, 0xFF};
MutablePackedBits bits(packed, 16);
bits.set(0, true);
bits.set(3, true);
bits.set(9, false);
EXPECT_EQ(packed[0], 0x09); // bits 0 and 3
EXPECT_EQ(packed[1], 0xFD); // bit 9 (bit 1 of byte 1) cleared
}
TEST(ModbusHelpersTest, MutablePackedBitsRoundTripAndConversion) {
const bool original[] = {true, true, false, true, false, false, false, false, true, false, true};
constexpr uint16_t count = sizeof(original);
uint8_t packed[(count + 7) / 8] = {};
MutablePackedBits out(packed, count);
for (uint16_t i = 0; i != count; i++)
out.set(i, original[i]);
PackedBits view = out; // implicit conversion to the read-only view
ASSERT_EQ(view.size(), count);
for (uint16_t i = 0; i != count; i++)
EXPECT_EQ(view[i], original[i]) << "bit " << i;
}
TEST(ModbusHelpersTest, PackedBitsViewContractsEnforced) {
uint8_t buf[8] = {};
PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer
EXPECT_EQ(view.bytes().size(), 2u);
MutablePackedBits bits(std::span<uint8_t>(buf, 2), 10);
bits.set(9, true); // in range: lands in byte 1
bits.set(10, true); // out of range: dropped
bits.set(300, true); // far out of range: dropped, no write past the span
MutablePackedBits short_bits(std::span<uint8_t>(buf, 1), 10); // contract-violating: 10 bits over 1 byte
short_bits.set(9, false); // within count_ but past the span: dropped (would clear bit 9 set above)
EXPECT_EQ(buf[1], 0x02);
for (size_t i = 2; i < sizeof(buf); i++)
EXPECT_EQ(buf[i], 0) << "byte " << i;
}
// server_pdu_payload() must never classify an exception PDU as a read: [fc|0x80, code] is 2 bytes, and a
// read-offset of 2 would return an empty span, losing the exception code. The payload of an exception PDU
// is the exception code byte, for reads and writes alike.
TEST(ModbusServerPduPayload, ExceptionOfReadYieldsExceptionCode) {
const uint8_t pdu[] = {0x83, 0x02}; // exception response to READ_HOLDING_REGISTERS
auto payload = server_pdu_payload(pdu);
ASSERT_EQ(payload.size(), 1u);
EXPECT_EQ(payload[0], 0x02);
}
TEST(ModbusServerPduPayload, ExceptionOfWriteYieldsExceptionCode) {
const uint8_t pdu[] = {0x86, 0x03}; // exception response to WRITE_SINGLE_REGISTER
auto payload = server_pdu_payload(pdu);
ASSERT_EQ(payload.size(), 1u);
EXPECT_EQ(payload[0], 0x03);
}
} // namespace esphome::modbus::helpers
@@ -18,7 +18,7 @@ binary_sensor:
modbus_controller_id: modbus_controller1
id: modbus_binary_sensor2
name: Test Binary Sensor with Lambda
register_type: read
register_type: input
address: 0x3201
lambda: |-
return x;
@@ -4,7 +4,7 @@
namespace esphome::modbus_server {
using modbus::ModbusExceptionCode;
using modbus::ExceptionCode;
using modbus::RegisterValues;
namespace {
@@ -29,7 +29,7 @@ TEST(ModbusServerWrite, SingleWordSucceeds) {
};
server.add_server_register(&reg);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234}));
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
EXPECT_FALSE(status.has_value()); // nullopt == success
EXPECT_EQ(written, 0x1234);
}
@@ -45,7 +45,7 @@ TEST(ModbusServerWrite, DwordSucceeds) {
};
server.add_server_register(&reg);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678}));
auto status = server.on_write_registers(0x0000, make_registers({0x1234, 0x5678}));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(written, 0x12345678);
}
@@ -70,10 +70,10 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) {
server.add_server_register(&dword_reg);
// Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs.
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1111, 0x2222}));
auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_VALUE);
EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied
EXPECT_FALSE(dword_written);
}
@@ -84,19 +84,19 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) {
ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set
server.add_server_register(&read_only);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234}));
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
ModbusServer server;
auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234}));
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
@@ -114,14 +114,14 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
server.add_server_register(&first);
server.add_server_register(&second);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB}));
auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
EXPECT_EQ(status.value(), ExceptionCode::SERVICE_DEVICE_FAILURE);
EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure
}
// --- on_modbus_read_registers --------------------------------------------------
// --- on_read_registers --------------------------------------------------
TEST(ModbusServerRead, SingleWordSucceeds) {
ModbusServer server;
@@ -130,7 +130,7 @@ TEST(ModbusServerRead, SingleWordSucceeds) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 1, out);
auto status = server.on_read_registers(0x0000, 1, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0], 0x1234);
@@ -143,7 +143,7 @@ TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 2, out);
auto status = server.on_read_registers(0x0000, 2, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0], 0x1234);
@@ -165,10 +165,10 @@ TEST(ModbusServerRead, StartInsideValueRejected) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0011, 1, out); // the second cell of the DWORD
auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_FALSE(read_called);
}
@@ -184,10 +184,10 @@ TEST(ModbusServerRead, ClippedTailRejected) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers
auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_FALSE(read_called);
}
@@ -200,10 +200,10 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 1, out);
auto status = server.on_read_registers(0x0000, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An unregistered address with courtesy enabled returns the default value for each cell.
@@ -213,7 +213,7 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0005, 2, out);
auto status = server.on_read_registers(0x0005, 2, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0], 0xABCD);
@@ -224,10 +224,10 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
ModbusServer server;
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0005, 1, out);
auto status = server.on_read_registers(0x0005, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// --- partial reads (opt-in) ----------------------------------------------------
@@ -241,7 +241,7 @@ TEST(ModbusServerRead, PartialReadHighWord) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0010, 1, out);
auto status = server.on_read_registers(0x0010, 1, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0], 0x1234);
@@ -256,7 +256,7 @@ TEST(ModbusServerRead, PartialReadLowWordFromInterior) {
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0011, 1, out);
auto status = server.on_read_registers(0x0011, 1, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0], 0x5678);
@@ -272,12 +272,12 @@ TEST(ModbusServerRead, PartialReadReversedType) {
server.add_server_register(&reg);
RegisterValues first;
ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value());
ASSERT_FALSE(server.on_read_registers(0x0010, 1, first).has_value());
ASSERT_EQ(first.size(), 1u);
EXPECT_EQ(first[0], 0x5678);
RegisterValues second;
ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value());
ASSERT_FALSE(server.on_read_registers(0x0011, 1, second).has_value());
ASSERT_EQ(second.size(), 1u);
EXPECT_EQ(second[0], 0x1234);
}
+13
View File
@@ -0,0 +1,13 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
cg.add_define("USE_NETWORK_IPV6", True)
manifest.to_code = to_code_testing
@@ -0,0 +1,23 @@
# Compiled dual-stack test: wifi + ethernet coexisting via network: priority:.
# This is the first build path that keeps both radios' stacks compiled in, so
# it must actually compile (not just validate) to guard the reconciler wiring.
# WiFi is listed first so the build also exercises the wifi-primary branch in
# network/util.cpp (the ethernet-primary branch matches the legacy order).
wifi:
ssid: MySSID
password: password1
ethernet:
type: W5500
clk_pin: GPIO19
mosi_pin: GPIO21
miso_pin: GPIO23
cs_pin: GPIO18
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 10Mhz
network:
priority:
- wifi
- ethernet
@@ -0,0 +1,208 @@
#include <gtest/gtest.h>
#include "esphome/components/network/ip_address.h"
#ifdef USE_HOST
#if USE_NETWORK_IPV6
namespace esphome::network::testing {
// =========================================================================
// IPv4
// =========================================================================
TEST(IPAddressHost, IPv4DefaultNotSet) {
IPAddress addr;
EXPECT_FALSE(addr.is_set());
}
TEST(IPAddressHost, IPv4DefaultIsIPv4) {
IPAddress addr;
EXPECT_TRUE(addr.is_ip4());
EXPECT_FALSE(addr.is_ip6());
}
TEST(IPAddressHost, IPv4ParseAndSerialize) {
IPAddress addr("192.168.1.1");
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "192.168.1.1");
}
TEST(IPAddressHost, IPv4FromOctets) {
IPAddress addr(192, 168, 1, 1);
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "192.168.1.1");
}
TEST(IPAddressHost, IPv4IsSet) {
IPAddress addr("192.168.1.1");
EXPECT_TRUE(addr.is_set());
}
TEST(IPAddressHost, IPv4IsIp4) {
IPAddress addr("192.168.1.1");
EXPECT_TRUE(addr.is_ip4());
EXPECT_FALSE(addr.is_ip6());
}
TEST(IPAddressHost, IPv4MulticastDetected) {
IPAddress addr("239.0.60.53");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4MulticastBoundaryLow) {
IPAddress addr("224.0.0.0");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4MulticastBoundaryHigh) {
IPAddress addr("239.255.255.255");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4UnicastNotMulticast) {
IPAddress addr("192.168.1.1");
EXPECT_FALSE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4EqualityMatch) {
IPAddress a("192.168.1.1");
IPAddress b("192.168.1.1");
EXPECT_EQ(a, b);
}
TEST(IPAddressHost, IPv4EqualityMismatch) {
IPAddress a("192.168.1.1");
IPAddress b("192.168.1.2");
EXPECT_NE(a, b);
}
TEST(IPAddressHost, IPv4FromOctetsMatchesParse) {
IPAddress from_octets(192, 168, 1, 1);
IPAddress from_string("192.168.1.1");
EXPECT_EQ(from_octets, from_string);
}
TEST(IPAddressHost, IPv4FromIPAddrT) {
ip_addr_t raw;
memset(&raw, 0, sizeof(raw));
raw.u_addr.ip4.s_addr = htonl((192u << 24) | (168u << 16) | (1u << 8) | 1u);
raw.type = IPADDR_TYPE_V4;
IPAddress addr(&raw);
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "192.168.1.1");
EXPECT_TRUE(addr.is_ip4());
EXPECT_FALSE(addr.is_ip6());
}
// =========================================================================
// IPv6
// =========================================================================
TEST(IPAddressHost, IPv6ParseAndSerialize) {
IPAddress addr("ff12::cafe");
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "ff12::cafe");
}
TEST(IPAddressHost, IPv6Loopback) {
IPAddress addr("::1");
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "::1");
}
TEST(IPAddressHost, IPv6IsIp6) {
IPAddress addr("ff12::cafe");
EXPECT_TRUE(addr.is_ip6());
EXPECT_FALSE(addr.is_ip4());
}
TEST(IPAddressHost, IPv6AllZerosNotSet) {
IPAddress addr("::");
EXPECT_FALSE(addr.is_set());
}
TEST(IPAddressHost, IPv6LoopbackIsSet) {
IPAddress addr("::1");
EXPECT_TRUE(addr.is_set());
}
TEST(IPAddressHost, IPv6MulticastDetected) {
IPAddress addr("ff12::cafe");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv6MulticastLinkLocal) {
IPAddress addr("ff02::1");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv6UnicastNotMulticast) {
IPAddress addr("::1");
EXPECT_FALSE(addr.is_multicast());
}
TEST(IPAddressHost, IPv6EqualityMatch) {
IPAddress a("ff12::cafe");
IPAddress b("ff12::cafe");
EXPECT_EQ(a, b);
}
TEST(IPAddressHost, IPv6EqualityMismatch) {
IPAddress a("ff12::cafe");
IPAddress b("ff02::1");
EXPECT_NE(a, b);
}
TEST(IPAddressHost, IPv6OutputIsLowercase) {
// inet_pton is case-insensitive; str_to must lowercase the output
IPAddress addr("FF12::CAFE");
char buf[IP_ADDRESS_BUFFER_SIZE];
addr.str_to(buf);
for (const char *p = buf; *p; ++p) {
EXPECT_FALSE(*p >= 'A' && *p <= 'F') << "uppercase letter in: " << buf;
}
}
TEST(IPAddressHost, IPv6FullAddressRoundTrip) {
// A full 128-bit address with no compression opportunity
const char *input = "fde0:983a:d0d3:a65e:725a:0fff:fe36:9916";
IPAddress addr(input);
char buf[IP_ADDRESS_BUFFER_SIZE];
addr.str_to(buf);
EXPECT_NE(buf[0], '\0');
EXPECT_NE(std::string(buf).find("fde0"), std::string::npos);
}
// =========================================================================
// Malformed input
// =========================================================================
TEST(IPAddressHost, MalformedIPv4YieldsEmptyAddress) {
IPAddress addr("not-an-ip");
EXPECT_FALSE(addr.is_set());
EXPECT_TRUE(addr.is_ip4());
}
TEST(IPAddressHost, MalformedIPv6YieldsEmptyAddress) {
// "gg::1" looks like IPv6 (contains ':') but fails inet_pton; addr stays
// zeroed (type=V4 from memset) so is_set() is false and is_ip4() is true.
IPAddress addr("gg::1");
EXPECT_FALSE(addr.is_set());
EXPECT_TRUE(addr.is_ip4());
}
// =========================================================================
// Cross-family
// =========================================================================
TEST(IPAddressHost, IPv4AndIPv6NotEqual) {
IPAddress v4("192.168.1.1");
IPAddress v6("::1");
EXPECT_NE(v4, v6);
}
} // namespace esphome::network::testing
#endif // USE_NETWORK_IPV6
#endif // USE_HOST
@@ -0,0 +1 @@
nrf52:
@@ -2,3 +2,5 @@ nrf52:
dfu: true
reg0:
voltage: 1.8V
framework:
libc_nano: false
+17 -12
View File
@@ -2,11 +2,9 @@ wifi:
ssid: MySSID
password: password1
# Purposely test that `online_image:` does auto-load `image:`
# Keep the `image:` undefined.
# image:
online_image:
- id: online_binary_image
image:
- platform: online_image
id: online_binary_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: BINARY
@@ -21,34 +19,41 @@ online_image:
} else {
ESP_LOGD("online_image", "Cache miss: fresh download");
}
- id: online_binary_transparent_image
- platform: online_image
id: online_binary_transparent_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
type: BINARY
transparency: chroma_key
format: png
- id: online_rgba_image
- platform: online_image
id: online_rgba_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: RGB
transparency: alpha_channel
- id: online_rgb24_image
- platform: online_image
id: online_rgb24_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: RGB
transparency: chroma_key
- id: online_binary_bmp
- platform: online_image
id: online_binary_bmp
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
format: BMP
type: BINARY
- id: online_rgb_bmp_8bit
- platform: online_image
id: online_rgb_bmp_8bit
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
format: BMP
type: RGB
- id: online_jpeg_image
- platform: online_image
id: online_jpeg_image
url: http://www.faqs.org/images/library.jpg
format: JPEG
type: RGB
- id: online_jpg_image
- platform: online_image
id: online_jpg_image
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
@@ -0,0 +1,22 @@
# Legacy top-level `online_image:` form (deprecated; migrates to
# `platform: online_image`). Config-only test exercising the deprecation path.
wifi:
ssid: MySSID
password: password1
http_request:
display:
- platform: sdl
id: online_image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
online_image:
- id: legacy_online_image
url: http://www.example.org/example.png
format: PNG
type: RGB565
resize: 50x50
@@ -28,3 +28,32 @@ text_sensor:
name: "PAN ID"
ext_pan_id:
name: "Extended PAN ID"
sensor:
- platform: openthread_info
parent_average_rssi:
name: "Parent Average RSSI"
parent_last_rssi:
name: "Parent Last RSSI"
parent_link_quality_in:
name: "Parent Link Quality In"
parent_link_quality_out:
name: "Parent Link Quality Out"
tx_total:
name: "TX Total"
tx_retries:
name: "TX Retries"
tx_err_cca:
name: "TX CCA Errors"
tx_err_abort:
name: "TX Abort Errors"
rx_total:
name: "RX Total"
rx_err_fcs:
name: "RX FCS Errors"
attach_attempts:
name: "Attach Attempts"
parent_changes:
name: "Parent Changes"
partition_id_changes:
name: "Partition ID Changes"
@@ -0,0 +1,4 @@
zephyr_ble_server:
ota:
- platform: zephyr_mcumgr
@@ -0,0 +1,52 @@
#include <gtest/gtest.h>
#include "esphome/components/ota/ota_backend.h"
namespace esphome::ota::testing {
// version_is_older(candidate, reference) == true means candidate is a downgrade
// and should be rejected.
TEST(VersionIsOlder, PatchOlder) {
EXPECT_TRUE(version_is_older("1.2.3", "1.2.4"));
EXPECT_FALSE(version_is_older("1.2.4", "1.2.3"));
}
TEST(VersionIsOlder, NumericNotLexical) {
// "1.10.0" is newer than "1.9.0" even though '1' < '9' lexically.
EXPECT_TRUE(version_is_older("1.9.0", "1.10.0"));
EXPECT_FALSE(version_is_older("1.10.0", "1.9.0"));
}
TEST(VersionIsOlder, MajorMinor) {
EXPECT_TRUE(version_is_older("1.9.9", "2.0.0"));
EXPECT_TRUE(version_is_older("1.2.9", "1.3.0"));
EXPECT_FALSE(version_is_older("2.0.0", "1.9.9"));
}
TEST(VersionIsOlder, EqualVersionsAllowed) {
// Re-flashing the same version must be permitted.
EXPECT_FALSE(version_is_older("1.2.3", "1.2.3"));
EXPECT_FALSE(version_is_older("2024.1.0", "2024.1.0"));
}
TEST(VersionIsOlder, DifferingComponentCounts) {
// Missing trailing components count as 0.
EXPECT_FALSE(version_is_older("1.2", "1.2.0"));
EXPECT_FALSE(version_is_older("1.2.0", "1.2"));
EXPECT_TRUE(version_is_older("1.2", "1.2.1"));
EXPECT_FALSE(version_is_older("1.2.1", "1.2"));
}
TEST(VersionIsOlder, CalendarVersions) {
EXPECT_TRUE(version_is_older("2024.12.0", "2025.1.0"));
EXPECT_FALSE(version_is_older("2025.1.0", "2024.12.0"));
}
TEST(VersionIsOlder, NullInputsAreSafe) {
EXPECT_FALSE(version_is_older(nullptr, "1.2.3"));
EXPECT_FALSE(version_is_older("1.2.3", nullptr));
EXPECT_FALSE(version_is_older(nullptr, nullptr));
}
} // namespace esphome::ota::testing
+20 -16
View File
@@ -7,36 +7,40 @@ udp:
addresses: ["239.0.60.53"]
packet_transport:
platform: udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
- platform: udp
id: transport_udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
sensor:
- platform: template
id: sensor_id1
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
id: our_id
remote_id: some_sensor_id
binary_sensor:
- platform: packet_transport
transport_id: transport_udp
provider: unencrypted-device
id: other_binary_sensor_id
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
type: status
name: Some-Device Status
@@ -3,36 +3,40 @@ udp:
addresses: ["239.0.60.53"]
packet_transport:
platform: udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
- platform: udp
id: transport_udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
sensor:
- platform: template
id: sensor_id1
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
id: our_id
remote_id: some_sensor_id
binary_sensor:
- platform: packet_transport
transport_id: transport_udp
provider: unencrypted-device
id: other_binary_sensor_id
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
type: status
name: Some-Device Status
+11
View File
@@ -4,6 +4,11 @@ audio_dac:
i2c_id: i2c_bus
address: 0x4D
bits_per_sample: 32bit
analog_gain: -6db
channel_mix: swapped
volume_min_db: -60dB
volume_max_db: -3dB
enable_pin: GPIO12
output:
- platform: gpio
@@ -22,3 +27,9 @@ binary_sensor:
number: 4
mode:
input: true
switch:
- platform: pcm5122
pcm5122: pcm5122_dac
name: PCM5122 Power Down
power_mode: powerdown
@@ -0,0 +1,4 @@
# Exercises the opt-in that compiles the RTC-backed preference storage into
# the ESP32 backend without any other option selecting it.
preferences:
rtc_storage: true
@@ -0,0 +1,25 @@
# Exercises the provisioning window: api registers as a provisioning source
# (encryption enabled, no key), the on_timeout automation, and the wifi +
# esp32_improv cross-component guards. improv_serial is intentionally NOT gated.
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
api:
encryption:
wifi:
ssid: MySSID
password: password1
improv_serial:
binary_sensor:
- platform: gpio
pin: 0
id: io0_button
esp32_improv:
authorizer: io0_button
@@ -0,0 +1,16 @@
# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source
# and the wifi reboot guard. improv_serial is present and intentionally NOT gated.
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
api:
encryption:
wifi:
ssid: MySSID
password: password1
improv_serial:
@@ -0,0 +1,15 @@
# A device provisioned over the network (wifi / Improv) with no api: network
# connectivity alone satisfies provisioning, so `provisioning:` is valid without an
# api encryption source. Config-only -- exercises the network provisioning-source
# validation path (the Improv-only case from the review).
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
wifi:
ssid: MySSID
password: password1
improv_serial:
@@ -198,7 +198,7 @@ button:
0xFF,
]
- platform: template
name: Haier
name: Haier Long
on_press:
remote_transmitter.transmit_haier:
code:
@@ -217,6 +217,21 @@ button:
0x00,
0x05,
]
- platform: template
name: Haier Short
on_press:
remote_transmitter.transmit_haier:
code:
[
0xA6,
0xDA,
0x00,
0x00,
0x40,
0x40,
0x00,
0x80,
]
- platform: template
name: Mirage
on_press:

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