Merge remote-tracking branch 'origin/dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-07-30 15:34:19 +12:00
294 changed files with 20081 additions and 7996 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()
```
+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
+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
+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
@@ -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
+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
@@ -212,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
@@ -21,7 +21,7 @@ esp32:
disable_fatfs: true
ota:
platform: esphome
- platform: esphome
wifi:
ssid: MySSID
+1
View File
@@ -74,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
@@ -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
@@ -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
+16
View File
@@ -295,6 +295,22 @@ lvgl:
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:
+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
@@ -39,6 +39,50 @@ 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;
@@ -93,14 +137,61 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) {
EXPECT_EQ(total, 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, 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
@@ -1,9 +1,13 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/hal.h"
namespace esphome::modbus::testing {
@@ -16,19 +20,21 @@ class NoResponseProbeHub : public ModbusClientHub {
public:
size_t queued_frames() const { return this->tx_buffer_.size(); }
const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); }
const ModbusDeviceCommand &queued(size_t i) const { return this->tx_buffer_[i]; }
bool waiting() const { return this->waiting_for_response_.has_value(); }
const ModbusDeviceCommand &waiting_command() const {
EXPECT_TRUE(this->waiting_for_response_.has_value());
return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access)
}
void send_next_for_test() { this->send_next_frame_(); }
void force_send_front() {
this->waiting_for_response_ = std::move(this->tx_buffer_.front());
this->tx_buffer_.pop_front();
}
// Drives the real unexpected-frame branch in process_modbus_server_frame().
void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) {
this->process_modbus_server_frame(address, function_code, data, len);
void receive_frame_for_test(uint8_t address, std::span<const uint8_t> pdu) {
this->process_modbus_server_frame(address, pdu);
}
void timeout_waiting() {
if (this->waiting_for_response_.has_value())
@@ -37,11 +43,11 @@ class NoResponseProbeHub : public ModbusClientHub {
}
};
// A device with a scripted answer to on_modbus_no_response().
// A device with a scripted answer to on_no_response().
class RetryingDevice : public ModbusClientDevice {
public:
RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {}
bool on_modbus_no_response() override {
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
return this->retry_;
}
@@ -55,7 +61,7 @@ class RetryingDevice : public ModbusClientDevice {
class ClearingRetryDevice : public ModbusClientDevice {
public:
ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
bool on_modbus_no_response() override {
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback
return true; // and still requests a retry
@@ -94,8 +100,9 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
EXPECT_EQ(requeued.device, &device);
// address + PDU + CRC
ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3);
EXPECT_EQ(requeued.frame.data.data()[0], 0x02);
EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU)));
EXPECT_EQ(requeued.frame.address(), 0x02);
ASSERT_EQ(requeued.frame.pdu().size(), sizeof(READ_PDU));
EXPECT_EQ(0, memcmp(requeued.frame.pdu().data(), READ_PDU, sizeof(READ_PDU)));
}
// A device that declines the retry has the frame dropped.
@@ -143,8 +150,8 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) {
hub.force_send_front();
// A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch.
const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload));
const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, stray_pdu);
EXPECT_EQ(device.no_response_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue...
@@ -175,4 +182,736 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) {
EXPECT_FALSE(hub.waiting());
}
// A device whose sent/not-sent callbacks are counted.
namespace {
class SentCountingDevice : public ModbusClientDevice {
public:
SentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override {
this->sent_count_++;
this->last_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
int sent_count_{0};
int not_sent_count_{0};
std::vector<uint8_t> last_sent_pdu_;
std::vector<uint8_t> last_not_sent_pdu_;
};
} // namespace
// on_sent() fires when the frame goes onto the wire, not when it is queued.
TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // frame timing derives from the baud rate
SentCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
EXPECT_EQ(device.sent_count_, 0); // queued only - nothing on the wire yet
hub.send_next_for_test();
EXPECT_EQ(device.sent_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
// The callback identifies which command transmitted: it carries the request PDU.
EXPECT_EQ(device.last_sent_pdu_, (std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU))));
EXPECT_TRUE(hub.waiting());
}
// Counts response deliveries so requeue semantics can be pinned end to end.
namespace {
class DataCountingDevice : public ModbusClientDevice {
public:
DataCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->data_count_++;
}
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override { this->error_count_++; }
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->last_no_response_pdu_.assign(request_pdu.begin(), request_pdu.end());
if (this->retries_ == 0)
return false;
this->retries_--;
return true;
}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_count_++; }
int terminals() const {
return this->data_count_ + this->error_count_ + this->no_response_count_ + this->not_sent_count_;
}
int data_count_{0};
int error_count_{0};
int no_response_count_{0};
int not_sent_count_{0};
int sent_count_{0};
int retries_{0};
std::vector<uint8_t> last_not_sent_pdu_;
std::vector<uint8_t> last_no_response_pdu_;
};
// Runs full send/respond cycles until the queue drains; returns the number of cycles executed.
int drain_with_responses(NoResponseProbeHub &hub, std::span<const uint8_t> response_pdu, int max_cycles = 10) {
int cycles = 0;
while (hub.queued_frames() != 0 && cycles < max_cycles) {
hub.force_send_front();
hub.receive_frame_for_test(0x02, response_pdu);
cycles++;
}
return cycles;
}
} // namespace
constexpr uint8_t OK_RESPONSE[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
// One request produces exactly one data callback.
TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
drain_with_responses(hub, OK_RESPONSE);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_FALSE(hub.waiting());
}
// An exception response is a terminal on its own: exactly one on_error(), no others,
// preceded by exactly one on_sent().
TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response);
EXPECT_EQ(device.error_count_, 1);
EXPECT_EQ(device.terminals(), 1);
EXPECT_EQ(device.sent_count_, 1);
}
// A timeout is a terminal on its own: exactly one on_no_response(), preceded by one
// on_sent(); a refused duplicate ends in on_not_sent() with NO on_sent().
TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.terminals(), 1);
EXPECT_EQ(device.sent_count_, 1);
// A refused send (empty PDU) is a not_sent terminal, never sent.
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.send_pdu(write_pdu);
device.send_pdu(std::span<const uint8_t>{});
EXPECT_EQ(device.not_sent_count_, 1);
EXPECT_EQ(device.terminals(), 2); // the accepted write is still queued - no terminal for it yet
EXPECT_EQ(device.sent_count_, 1); // and it has not transmitted yet
// Drain it: the write echo response is its data terminal, and the books balance.
hub.send_next_for_test();
hub.receive_frame_for_test(0x02, write_pdu);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.terminals(), 3); // 3 accepted lifecycles, 3 terminals
EXPECT_EQ(device.sent_count_, 2); // 2 transmissions (read + write); the refused send never sent
}
// A device-requested retry starts a new lifecycle: each transmission gets its own sent + terminal.
TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1; // ask for exactly one retry
device.send_pdu(read_pdu());
hub.send_next_for_test();
hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued)
ASSERT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test();
hub.timeout_waiting(); // lifecycle 2: sent + no_response (retry declined -> done)
EXPECT_EQ(device.no_response_count_, 2);
EXPECT_EQ(device.terminals(), 2);
EXPECT_EQ(device.sent_count_, 2);
EXPECT_EQ(hub.queued_frames(), 0u);
// The retried lifecycle's timeout carries the SAME request PDU as the first attempt.
EXPECT_EQ(device.last_no_response_pdu_, std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU)));
}
// A retry re-queue that finds the buffer full is refused like any other send: the device gets
// on_not_sent() carrying the request PDU (the previously uncovered requeue_waiting_frame_ branch).
TEST(ModbusClientHubCallbackCount, FullQueueRetryRefusalDeliversNotSentWithPdu) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1;
SentCountingDevice filler(&hub, 0x05);
device.send_pdu(read_pdu());
hub.force_send_front(); // in flight
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
hub.timeout_waiting(); // retry requested, but the re-queue is refused: not_sent terminal instead
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.not_sent_count_, 1);
EXPECT_EQ(device.last_not_sent_pdu_, std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU)));
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
}
// The deprecated device-side send_raw() refusal delivers through the same guard as every other
// path: a handler that reacts to its own refusal with another empty send_raw() stays bounded.
namespace {
class SendRawOnNotSentDevice : public ModbusClientDevice {
public:
SendRawOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->send_raw({}); // refused again; the guard must suppress the nested delivery
#pragma GCC diagnostic pop
}
int not_sent_count_{0};
};
} // namespace
TEST(ModbusClientHubQueue, SendRawRefusalIsGuardedAgainstRecursion) {
NoResponseProbeHub hub;
SendRawOnNotSentDevice device(&hub, 0x02);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
device.send_raw({}); // empty payload refused -> on_not_sent -> nested send_raw({}) suppressed
#pragma GCC diagnostic pop
EXPECT_EQ(device.not_sent_count_, 1);
}
namespace {
// A device that chains a follow-up send from inside on_sent().
class ChainOnSentDevice : public ModbusClientDevice {
public:
ChainOnSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override {
if (!this->chained_) {
this->chained_ = true;
const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1
this->send_pdu(follow);
}
}
bool chained_{false};
};
} // namespace
// clear_tx_queue_for_address() resolves every dropped frame via its owner's on_not_sent(), so a device
// sharing the address with the clearer (e.g. a modbus_client action alongside an offline controller)
// observes the drop; frames for other addresses are untouched.
TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) {
NoResponseProbeHub hub;
SentCountingDevice controller_like(&hub, 0x02);
SentCountingDevice bystander_same(&hub, 0x02);
SentCountingDevice bystander_other(&hub, 0x03);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02};
controller_like.send_pdu(read_a);
bystander_same.send_pdu(read_b);
bystander_other.send_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
controller_like.clear_tx_queue_for_address(false);
ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains
EXPECT_EQ(hub.front().frame.address(), 0x03);
EXPECT_EQ(controller_like.not_sent_count_, 1);
EXPECT_EQ(bystander_same.not_sent_count_, 1);
EXPECT_EQ(bystander_other.not_sent_count_, 0);
// each owner saw its own request PDU
EXPECT_EQ(bystander_same.last_not_sent_pdu_, std::vector<uint8_t>(std::begin(read_b), std::end(read_b)));
}
namespace {
// Re-sends its frame once from inside on_not_sent - the re-queued frame must survive the sweep.
class ResendOnNotSentDevice : public ModbusClientDevice {
public:
ResendOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->not_sent_count_ == 1) {
const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01};
this->send_pdu(again);
}
}
int not_sent_count_{0};
};
} // namespace
// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep nor
// loops it: only initially-marked frames are swept, so the re-queued frame stays queued.
TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) {
NoResponseProbeHub hub;
ResendOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.clear_tx_queue_for_address(0x02, false);
// The original frame resolved via on_not_sent; the re-send from inside that callback remains queued.
EXPECT_EQ(device.not_sent_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.address(), 0x02);
}
namespace {
// Retries from EVERY on_not_sent - against a full queue this recursed without bound before the guard.
class AlwaysRetryDevice : public ModbusClientDevice {
public:
AlwaysRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01};
this->send_pdu(again);
}
int not_sent_count_{0};
};
// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device
// guard suppresses deliveries only to a device already inside its own on_not_sent()).
class ClearOtherOnNotSentDevice : public ModbusClientDevice {
public:
ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->parent_->clear_tx_queue_for_address(0x03, false);
}
int not_sent_count_{0};
};
} // namespace
// A handler that retries from every on_not_sent() against a FULL queue must not recurse: the first
// refusal notifies once, the nested refusal is dropped without a callback (the documented guard).
TEST(ModbusClientHubQueue, FullQueueRetryFromNotSentDoesNotRecurse) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
AlwaysRetryDevice retrier(&hub, 0x02);
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
retrier.send_pdu(read); // refused (full) -> on_not_sent -> retry -> refused under the guard, silently
EXPECT_EQ(retrier.not_sent_count_, 1);
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
}
namespace {
// From inside on_not_sent, triggers ANOTHER device's send (which will be refused too).
class SendOtherOnNotSentDevice : public ModbusClientDevice {
public:
SendOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->other_ != nullptr) {
const uint8_t read[] = {0x03, 0x00, 0x60, 0x00, 0x01};
this->other_->send_pdu(read);
}
}
ModbusClientDevice *other_{nullptr};
int not_sent_count_{0};
};
} // namespace
// The refusal recursion guard is per-device: a refusal that lands on a DIFFERENT device while one
// device's notification is on the stack must still deliver - that device did not cause the recursion
// and would otherwise silently lose its terminal callback.
TEST(ModbusClientHubQueue, RefusalForOtherDeviceDeliversDuringNotification) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SendOtherOnNotSentDevice first(&hub, 0x02);
SentCountingDevice second(&hub, 0x03);
first.other_ = &second;
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
first.send_pdu(read); // refused -> first.on_not_sent -> second's send refused -> second notified
EXPECT_EQ(first.not_sent_count_, 1);
EXPECT_EQ(second.not_sent_count_, 1);
}
// Two devices whose handlers each trigger the other's send cannot recurse without bound: each device
// can be on the notification stack at most once, so the cycle dies as soon as it returns to a device
// whose own on_not_sent() is still running.
TEST(ModbusClientHubQueue, TwoDeviceRefusalCycleTerminates) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SendOtherOnNotSentDevice first(&hub, 0x02);
SendOtherOnNotSentDevice second(&hub, 0x03);
first.other_ = &second;
second.other_ = &first;
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
first.send_pdu(read); // refuse -> first -> second refused -> second -> first suppressed -> unwind
EXPECT_EQ(first.not_sent_count_, 1);
EXPECT_EQ(second.not_sent_count_, 1);
}
namespace {
// From inside on_not_sent, clears its OWN address - its remaining queued frames resolve silently
// (the guard suppresses self-deliveries), while other owners on the address are still notified.
class ClearOwnAddressOnNotSentDevice : public ModbusClientDevice {
public:
ClearOwnAddressOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->clear_tx_queue_for_address(/*clear_sent=*/false);
}
int not_sent_count_{0};
};
} // namespace
// The documented cost of the per-device guard: a clear issued from inside your own on_not_sent()
// resolves your remaining frames silently (like clear_tx_queue_for_device() - you cleared them, you
// know), while other owners sharing the address are still notified.
TEST(ModbusClientHubQueue, SelfClearFromNotSentSilentForClearerNotifiesOthers) {
NoResponseProbeHub hub;
ClearOwnAddressOnNotSentDevice clearer(&hub, 0x02);
SentCountingDevice bystander(&hub, 0x02);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01};
clearer.send_pdu(read_a);
clearer.send_pdu(read_b);
bystander.send_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
clearer.send_pdu(std::span<const uint8_t>{}); // refused (empty) -> the handler clears the shared address
EXPECT_EQ(clearer.not_sent_count_, 1); // only the refusal; the two swept frames resolve silently
EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's swept frame is still notified
EXPECT_EQ(hub.queued_frames(), 0u);
}
// The guard must not over-suppress: a sweep started from inside on_not_sent() still delivers its
// victims' notifications (only nested refusals are silenced).
TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) {
NoResponseProbeHub hub;
ClearOtherOnNotSentDevice clearer(&hub, 0x02);
SentCountingDevice victim(&hub, 0x03);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
clearer.send_pdu(read_a);
victim.send_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.clear_tx_queue_for_address(0x02, false); // clearer's on_not_sent clears address 0x03 in turn
EXPECT_EQ(clearer.not_sent_count_, 1);
EXPECT_EQ(victim.not_sent_count_, 1); // delivered despite arriving from a nested sweep
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// tx_blocked() flips to blocked after the first check, so send_next_frame_() passes its own gate but
// send_frame_() refuses - a deterministic transmit failure.
class FlakyBlockHub : public NoResponseProbeHub {
public:
bool tx_blocked() override {
this->tx_blocked_calls_++;
return this->tx_blocked_calls_ > 1;
}
int tx_blocked_calls_{0};
};
// Reacts to a transmit failure by sending another frame from inside the failure callback.
class WriteOnNotSentDevice : public ModbusClientDevice {
public:
WriteOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
const uint8_t write[] = {0x06, 0x00, 0x40, 0x01, 0x02};
this->send_pdu(write);
}
int not_sent_count_{0};
};
} // namespace
// A transmit failure must resolve with the failed frame OUT of the queue before its on_not_sent runs: a
// handler that reacts by sending a new frame must not have that frame discarded by the pop that
// follows - the failed frame is popped first, the new frame survives.
TEST(ModbusClientHubQueue, TransmitFailurePopsBeforeNotify) {
FlakyBlockHub hub;
WriteOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test(); // tx_blocked gate passes, send_frame_ refuses -> failure path
EXPECT_EQ(device.not_sent_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // the handler's write survives...
EXPECT_EQ(hub.front().frame.pdu()[0], 0x06); // ...and it is the write, not the failed read
}
// clear_tx_queue_for_device() drops queued frames SILENTLY - no terminal callback (the documented
// exception to the exactly-one-terminal contract; used during teardown/offline handling).
TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
device.send_pdu(read_a);
device.send_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
device.clear_tx_queue_for_device();
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback
}
// A send_pdu() from inside on_sent() enqueues behind the in-flight frame rather than sending
// immediately or corrupting the in-flight transaction.
TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
ChainOnSentDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test(); // first frame goes on the wire -> on_sent chains a follow-up
EXPECT_TRUE(hub.waiting()); // first frame is in flight
ASSERT_EQ(hub.queued_frames(), 1u); // the follow-up queued behind it, not sent
EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x09); // it is the chained read (start address 0x0009)
}
namespace {
// Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so
// external devices written against the old names keep working through the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class LegacyNameDevice : public ModbusClientDevice {
public:
LegacyNameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_modbus_not_sent() override { this->legacy_not_sent_++; }
bool on_modbus_no_response() override {
this->legacy_no_response_++;
return false;
}
int legacy_not_sent_{0};
int legacy_no_response_{0};
};
#pragma GCC diagnostic pop
} // namespace
TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
hub.force_send_front();
hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response
EXPECT_EQ(device.legacy_no_response_, 1);
device.send_pdu(std::span<const uint8_t>()); // empty PDU refused -> on_not_sent -> forwards
EXPECT_EQ(device.legacy_not_sent_, 1);
}
// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU
// 256-byte limit, so it is refused up front and signalled like any other failed send.
TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
std::vector<uint8_t> big(MAX_PDU_SIZE + 1, 0x41);
device.send_pdu(big);
EXPECT_EQ(device.legacy_not_sent_, 1); // on_not_sent, observed via the legacy forward
EXPECT_TRUE(hub.tx_buffer_empty());
}
// --- ModbusDevice compatibility shim ------------------------------------------------------------
// External components written against the pre-2026.8 API subclass ModbusDevice and override the
// old callbacks; the shim adapts the span-based hooks back to those signatures.
namespace {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class LegacyApiDevice : public ModbusDevice {
public:
LegacyApiDevice(ModbusClientHub *hub, uint8_t address) : ModbusDevice(hub, address) {}
void on_modbus_data(const std::vector<uint8_t> &data) override { this->last_data_ = data; }
void on_modbus_error(uint8_t function_code, uint8_t exception_code) override {
this->last_error_fc_ = function_code;
this->last_error_code_ = exception_code;
}
std::vector<uint8_t> last_data_;
int last_error_fc_{-1};
int last_error_code_{-1};
};
#pragma GCC diagnostic pop
} // namespace
TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) {
NoResponseProbeHub hub;
LegacyApiDevice device(&hub, 0x02);
// Read response: on_modbus_data() historically received the payload after the function code and
// the byte-count byte, as an owning vector.
const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02};
device.send_pdu(read_req);
hub.force_send_front();
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, response);
const std::vector<uint8_t> expected{0x00, 0x2A, 0x01, 0x00};
EXPECT_EQ(device.last_data_, expected);
// Write echo: no byte-count byte, so the payload is everything after the function code.
const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
device.send_pdu(write_req);
hub.force_send_front();
hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request
const std::vector<uint8_t> expected_echo{0x00, 0x10, 0x00, 0x2A};
EXPECT_EQ(device.last_data_, expected_echo);
// Exception response: on_modbus_error() received the masked function code and the exception code.
device.send_pdu(read_req);
hub.force_send_front();
const uint8_t error[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, error);
EXPECT_EQ(device.last_error_fc_, 0x03);
EXPECT_EQ(device.last_error_code_, 0x02);
}
// --- typed send helpers --------------------------------------------------------------------------
// Each helper is a one-line forward onto a merged builder; these pin the function code and wire
// bytes each one queues, so a swapped code or transposed field cannot survive review silently.
TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
auto check = [&](const std::vector<uint8_t> &expected) {
ASSERT_EQ(hub.queued_frames(), 1u);
auto pdu = hub.front().frame.pdu();
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
hub.force_send_front();
hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame
};
device.read_holding_registers(0x0102, 3);
check({0x03, 0x01, 0x02, 0x00, 0x03});
device.read_input_registers(0x0010, 2);
check({0x04, 0x00, 0x10, 0x00, 0x02});
device.read_coils(0x0020, 10);
check({0x01, 0x00, 0x20, 0x00, 0x0A});
device.read_discrete_inputs(0x0030, 1);
check({0x02, 0x00, 0x30, 0x00, 0x01});
device.write_single_register(0x0040, 0xABCD);
check({0x06, 0x00, 0x40, 0xAB, 0xCD});
device.write_single_coil(0x0041, true);
check({0x05, 0x00, 0x41, 0xFF, 0x00});
device.write_single_coil(0x0041, false);
check({0x05, 0x00, 0x41, 0x00, 0x00});
const uint16_t regs[] = {0x000B, 0x0016};
device.write_multiple_registers(0x0050, regs);
check({0x10, 0x00, 0x50, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16});
const bool coils[] = {true, false, true};
device.write_multiple_coils(0x0060, coils);
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
const uint8_t packed[] = {0x05};
device.write_multiple_coils(0x0060, PackedBits(packed, 3)); // packed overload, same wire bytes
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
}
TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
device.read_entities(EntityType::HOLDING, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.pdu()[0], 0x03);
hub.force_send_front();
hub.timeout_waiting();
device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.pdu()[0], 0x02);
hub.force_send_front();
hub.timeout_waiting();
device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A rejected read_entities() signals on_not_sent() like every other refused send.
namespace {
class NotSentCountingDevice : public ModbusClientDevice {
public:
NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_++; }
int not_sent_{0};
};
} // namespace
TEST(ModbusTypedSendHelpers, InvalidReadEntitiesSignalsNotSent) {
NoResponseProbeHub hub;
NotSentCountingDevice device(&hub, 0x02);
device.read_entities(EntityType::CUSTOM, 0x0001, 1);
EXPECT_EQ(device.not_sent_, 1);
EXPECT_EQ(hub.queued_frames(), 0u);
}
} // namespace esphome::modbus::testing
+332 -1
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,6 +306,31 @@ 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_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value());
@@ -229,4 +381,183 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
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 {
@@ -73,7 +73,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) {
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);
}
@@ -87,7 +87,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) {
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.
@@ -96,7 +96,7 @@ TEST(ModbusServerWrite, UnmatchedAddressRejected) {
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
@@ -117,7 +117,7 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
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
}
@@ -168,7 +168,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) {
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);
}
@@ -187,7 +187,7 @@ TEST(ModbusServerRead, ClippedTailRejected) {
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);
}
@@ -203,7 +203,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) {
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.
@@ -227,7 +227,7 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
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) ----------------------------------------------------
+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,4 @@
zephyr_ble_server:
ota:
- platform: zephyr_mcumgr
+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
@@ -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:
@@ -0,0 +1,11 @@
# Compile with OTA rollback support active (ota + safe_mode on ESP-IDF, the
# default) but boot_is_good_on_shutdown disabled, so an orderly shutdown does
# not confirm the app image; only boot_is_good_after / mark_successful do.
packages:
safe_mode: !include common-enabled.yaml
safe_mode:
boot_is_good_on_shutdown: false
ota:
- platform: esphome
+5 -4
View File
@@ -24,10 +24,10 @@ sensor:
name: PM <10µm Weight concentration
id: pm_10_0
accuracy_decimals: 1
nox:
name: NOx
voc:
name: VOC
nox_index:
name: NOx Index
voc_index:
name: VOC Index
algorithm_tuning:
index_offset: 100
learning_time_offset_hours: 12
@@ -42,4 +42,5 @@ sensor:
auto_cleaning_interval: 604800s
acceleration_mode: low
store_baseline: true
model: sen55
address: 0x69
+4 -4
View File
@@ -26,10 +26,10 @@ sensor:
name: PM <10µm Weight concentration
id: sen6x_pm_10_0
accuracy_decimals: 1
nox:
name: NOx
voc:
name: VOC
nox_index:
name: NOx Index
voc_index:
name: VOC Index
co2:
name: Carbon Dioxide
formaldehyde:
+3 -3
View File
@@ -1,7 +1,7 @@
sensor:
- platform: sgp4x
i2c_id: i2c_bus
voc:
voc_index:
name: VOC Index
id: sgp40_voc_index
algorithm_tuning:
@@ -11,8 +11,8 @@ sensor:
gating_max_duration_minutes: 180
std_initial: 50
gain_factor: 230
nox:
name: NOx
nox_index:
name: NOx Index
algorithm_tuning:
index_offset: 100
learning_time_offset_hours: 12
+1 -1
View File
@@ -2,7 +2,7 @@ udp:
addresses: ["239.0.60.53"]
time:
platform: host
- platform: host
syslog:
port: 514
+11
View File
@@ -4,6 +4,17 @@ wifi:
binary_sensor:
cover:
- platform: template
name: "Template Cover Assumed"
# assumed_state must be reflected in the web_server JSON (detail=all)
assumed_state: true
lambda: 'return COVER_OPEN;'
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
fan:
light:
sensor: