Merge branch 'neutral-ble-client' into radon-eye-single-node

This commit is contained in:
J. Nick Koston
2026-09-02 11:36:05 +02:00
397 changed files with 22510 additions and 11532 deletions
+5 -1
View File
@@ -61,8 +61,12 @@ api:
reboot_timeout: 0min
actions:
- action: hello_world
description: Log a greeting
variables:
name: string
name:
type: string
description: Name to greet
example: World
then:
- logger.log:
format: Hello World %s!
+2 -1
View File
@@ -1,4 +1,5 @@
<<: !include common-base.yaml
packages:
base: !include common-base.yaml
api:
encryption:
@@ -54,7 +54,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) {
size_t ref_len = reference_encode(mac, ref_buf);
APIBuffer api_buf;
api_buf.resize(16);
ASSERT_TRUE(api_buf.resize(16));
uint8_t *pos = api_buf.data();
#ifdef ESPHOME_DEBUG_API
uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size();
+68
View File
@@ -1,4 +1,5 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
@@ -280,4 +281,71 @@ TEST(Base64, Rfc4648Vectors) {
}
}
// --- step_to_accuracy_decimals() ---
TEST(StepToAccuracyDecimals, TypicalSteps) {
EXPECT_EQ(step_to_accuracy_decimals(0.001f), 3);
EXPECT_EQ(step_to_accuracy_decimals(0.005f), 3);
EXPECT_EQ(step_to_accuracy_decimals(0.01f), 2);
EXPECT_EQ(step_to_accuracy_decimals(0.025f), 3);
EXPECT_EQ(step_to_accuracy_decimals(0.05f), 2);
EXPECT_EQ(step_to_accuracy_decimals(0.1f), 1);
EXPECT_EQ(step_to_accuracy_decimals(0.25f), 2);
EXPECT_EQ(step_to_accuracy_decimals(0.5f), 1);
EXPECT_EQ(step_to_accuracy_decimals(1.5f), 1);
EXPECT_EQ(step_to_accuracy_decimals(2.5f), 1);
}
TEST(StepToAccuracyDecimals, WholeSteps) {
EXPECT_EQ(step_to_accuracy_decimals(1.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(2.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(5.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(10.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(100.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(1000.0f), 0);
}
TEST(StepToAccuracyDecimals, FiveSignificantDigits) {
EXPECT_EQ(step_to_accuracy_decimals(1.23456f), 4);
EXPECT_EQ(step_to_accuracy_decimals(12.345f), 3);
EXPECT_EQ(step_to_accuracy_decimals(123.45f), 2);
EXPECT_EQ(step_to_accuracy_decimals(1234.5f), 1);
EXPECT_EQ(step_to_accuracy_decimals(12345.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(0.33333f), 5);
EXPECT_EQ(step_to_accuracy_decimals(0.0001f), 4);
}
TEST(StepToAccuracyDecimals, TrailingZerosDropped) {
EXPECT_EQ(step_to_accuracy_decimals(0.3f), 1);
EXPECT_EQ(step_to_accuracy_decimals(0.7f), 1);
EXPECT_EQ(step_to_accuracy_decimals(0.125f), 3);
EXPECT_EQ(step_to_accuracy_decimals(0.0625f), 4);
}
TEST(StepToAccuracyDecimals, RoundsUpToWholeNumber) {
// Rounds to five significant digits first, so this becomes 10 with no decimals.
EXPECT_EQ(step_to_accuracy_decimals(9.999999f), 0);
}
TEST(StepToAccuracyDecimals, OutsideFixedNotationRange) {
// %.5g would print these in exponent form; the count is now the real one rather than a parse of "1e-05".
EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 5);
EXPECT_EQ(step_to_accuracy_decimals(0.000125f), 6);
EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(1000000.0f), 0);
}
TEST(StepToAccuracyDecimals, SignIgnored) {
EXPECT_EQ(step_to_accuracy_decimals(-0.1f), 1);
EXPECT_EQ(step_to_accuracy_decimals(-0.25f), 2);
EXPECT_EQ(step_to_accuracy_decimals(-1.0f), 0);
}
TEST(StepToAccuracyDecimals, NonFiniteAndZero) {
EXPECT_EQ(step_to_accuracy_decimals(0.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(NAN), 0);
EXPECT_EQ(step_to_accuracy_decimals(INFINITY), 0);
EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0);
}
} // namespace esphome::core::testing
+3
View File
@@ -0,0 +1,3 @@
sensor:
- platform: d01
name: D01 PM2.5 Concentration
+7
View File
@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
d01: !include common.yaml
@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO0
rx_pin: GPIO2
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
d01: !include common.yaml
@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
d01: !include common.yaml
+3
View File
@@ -0,0 +1,3 @@
sensor:
- platform: ds1603l
name: ds1603l Distance
@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO1
rx_pin: GPIO3
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
ds1603l: !include common.yaml
@@ -0,0 +1,7 @@
substitutions:
tx_pin: GPIO0
rx_pin: GPIO2
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
ds1603l: !include common.yaml
@@ -255,3 +255,45 @@ display:
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0));
# Waveshare 7.5" V2 mono (800x480, UC8179 controller, EPD_7in5_V2)
# full_update_every > 1 exercises the fast/partial refresh paths
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-7.5in-v2
full_update_every: 4
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, 100, Color::BLACK);
# Seeed reTerminal E1001 - 7.5" mono e-paper (800x480, UC8179)
# Pins overridden to avoid conflicts with the E1002 defaults above
- platform: epaper_spi
spi_id: spi_bus
model: seeed-reterminal-e1001
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
@@ -0,0 +1,6 @@
# The builder test compares against the Improv library's build_rpc_response,
# so the library must be part of the unit test build.
# Keep the version in sync with the pin in esphome/components/improv_base/__init__.py.
esphome:
libraries:
- improv/Improv@1.2.7
@@ -0,0 +1,102 @@
#include <gtest/gtest.h>
#include <array>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include <improv.h>
namespace esphome::improv_base::testing {
namespace {
std::vector<uint8_t> build_with_builder(improv::Command command, const std::vector<std::string> &datum,
bool add_checksum) {
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
improv::RpcResponseBuilder builder(buf, command);
for (const auto &str : datum) {
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
}
auto out = builder.finish(add_checksum);
return {out.begin(), out.end()};
}
} // namespace
// The serial path sends builder output where build_rpc_response bytes went before,
// so the two must match exactly, including the trailing 0x00 when checksums are off.
TEST(RpcResponseBuilder, ByteIdenticalToBuildRpcResponse) {
const std::vector<std::string> device_info = {"ESPHome", "2026.9.0", "ESP32", "test-device"};
const std::vector<std::string> network = {"MySSID", "-67", "YES"};
const std::vector<std::string> empty = {};
const std::vector<std::string> max_payload = {std::string(254, 'x')};
for (bool add_checksum : {false, true}) {
for (const auto *datum : {&device_info, &network, &empty, &max_payload}) {
EXPECT_EQ(build_with_builder(improv::GET_DEVICE_INFO, *datum, add_checksum),
improv::build_rpc_response(improv::GET_DEVICE_INFO, *datum, add_checksum));
}
}
}
// Golden bytes independent of the library: command, data length, string entries,
// then the trailing byte (0x00 without checksum, additive checksum with).
TEST(RpcResponseBuilder, GoldenBytes) {
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {}, false), (std::vector<uint8_t>{0x04, 0x00, 0x00}));
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, false),
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0x00}));
// Checksum: 0x04 + 0x03 + 0x02 + 'a' + 'b' = 0xCC
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, true),
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0xCC}));
}
// esp32_improv calls finish() and build_rpc_response() with no checksum flag,
// so the two defaults must agree
TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) {
const std::vector<std::string> urls = {"https://example.com"};
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
for (const auto &str : urls) {
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
}
auto out = builder.finish();
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), improv::build_rpc_response(improv::WIFI_SETTINGS, urls));
}
TEST(RpcResponseBuilder, PayloadBudget) {
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
// 254 byte string fills the payload exactly; a second entry no longer fits
improv::RpcResponseBuilder full(buf, improv::GET_DEVICE_INFO);
const std::string big(254, 'x');
EXPECT_TRUE(full.add_string(big.c_str(), big.size()));
EXPECT_FALSE(full.add_string("y", 1));
// 255 byte string can never fit (its length byte would exceed the budget)
improv::RpcResponseBuilder over(buf, improv::GET_DEVICE_INFO);
const std::string too_big(255, 'y');
EXPECT_FALSE(over.add_string(too_big.c_str(), too_big.size()));
// A wildly out of range length must not wrap the position arithmetic
EXPECT_FALSE(over.add_string("z", static_cast<size_t>(-1)));
auto out = over.finish(false);
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), (std::vector<uint8_t>{0x03, 0x00, 0x00}));
}
TEST(RpcResponseBuilder, FinishIsIdempotent) {
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
EXPECT_TRUE(builder.add_string("abc", 3));
auto first = builder.finish(true);
const std::vector<uint8_t> expected(first.begin(), first.end());
EXPECT_FALSE(builder.add_string("late", 4));
auto again = builder.finish(true);
EXPECT_EQ(std::vector<uint8_t>(again.begin(), again.end()), expected);
// The checksum flag on a later call is ignored
auto no_checksum = builder.finish(false);
EXPECT_EQ(std::vector<uint8_t>(no_checksum.begin(), no_checksum.end()), expected);
}
} // namespace esphome::improv_base::testing
@@ -0,0 +1,17 @@
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 17
cs_pin: 18
interrupt_pin: 36
reset_pin: 12
clock_speed: 10Mhz
logger:
hardware_uart: UART0
# Exercises the per-interface webserver URL collection at compile time
web_server:
improv_serial:
@@ -0,0 +1,11 @@
wifi:
ssid: MySSID
password: password1
# Serial logging off; on a dedicated UART bus improv_serial must not
# require the logger's serial settings
logger:
baud_rate: 0
improv_serial:
uart_id: uart_bus
@@ -5,4 +5,6 @@ wifi:
logger:
hardware_uart: UART0
# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_
improv_serial:
next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}}
@@ -0,0 +1,2 @@
packages:
improv_serial: !include common-ethernet.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
improv_serial: !include common-uart-bus.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
improv_serial: !include common-uart-bus.yaml
@@ -0,0 +1,92 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include "esphome/components/light/esp_color_correction.h"
namespace esphome::light::testing {
namespace {
// A representative fixture for ESPColorCorrection/gamma_table_reverse_search tests below --
// not a spec for generate_gamma_table() itself, which the Python tests own.
std::array<uint16_t, 256> build_gamma_table(double gamma) {
std::array<uint16_t, 256> table{};
table[0] = 0;
for (int i = 1; i < 256; i++) {
double raw = std::round(std::pow(i / 255.0, gamma) * 65535.0);
table[i] = static_cast<uint16_t>(std::max(1.0, std::min(65535.0, raw)));
}
return table;
}
// Bundles a table with an ESPColorCorrection pointing at it, since the correction only holds
// a raw pointer into the table and doesn't own it.
struct GammaFixture {
explicit GammaFixture(double gamma) : table(build_gamma_table(gamma)) { correction.set_gamma_table(table.data()); }
std::array<uint16_t, 256> table;
ESPColorCorrection correction;
};
} // namespace
// Regression test for esphome/esphome#18842: ESPColorCorrection's own 16-bit -> 8-bit
// conversion must never round a non-zero table entry down to a zero 8-bit output.
TEST(GammaCorrection, NonZeroInputsSurviveConversion) {
for (double gamma : {1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0}) {
GammaFixture fixture(gamma);
for (int i = 1; i < 256; i++) {
EXPECT_GE(fixture.correction.color_correct_red(i), 1) << "gamma=" << gamma << " index=" << i;
}
}
}
TEST(GammaCorrection, ZeroInputStaysZero) {
for (double gamma : {1.0, 2.2, 2.8, 4.0}) {
GammaFixture fixture(gamma);
EXPECT_EQ(fixture.correction.color_correct_red(0), 0) << "gamma=" << gamma;
}
}
TEST(GammaCorrection, FullBrightnessStaysFull) {
for (double gamma : {1.0, 2.2, 2.8, 4.0}) {
GammaFixture fixture(gamma);
EXPECT_EQ(fixture.correction.color_correct_red(255), 255) << "gamma=" << gamma;
}
}
// Reproduces the reporter's own numbers from esphome/esphome#18842 at gamma=2.8: codes
// 1-27 previously collapsed to an 8-bit output of 0 and must now be non-zero.
TEST(GammaCorrection, DeadZoneFixedAtGamma28) {
GammaFixture fixture(2.8);
for (int i = 1; i < 28; i++) {
EXPECT_GE(fixture.correction.color_correct_red(i), 1) << "index=" << i << " still collapses to 0";
}
}
TEST(GammaCorrection, ReverseSearchFindsLargestIndexLessEqualTarget) {
auto table = build_gamma_table(2.8);
for (uint16_t target : {0, 128, 129, 135, 1000, 32768, 65535}) {
uint8_t lo = gamma_table_reverse_search(table.data(), target);
EXPECT_LE(table[lo], target) << "target=" << target;
if (lo < 255) {
EXPECT_GT(table[lo + 1], target) << "target=" << target;
}
}
}
// color_uncorrect_* binary-searches the table via gamma_table_reverse_search().
TEST(GammaCorrection, UncorrectStaysMonotonic) {
GammaFixture fixture(2.8);
uint8_t prev = 0;
for (int i = 1; i < 256; i++) {
uint8_t result = fixture.correction.color_uncorrect_red(i);
EXPECT_GE(result, prev) << "index=" << i;
prev = result;
}
}
} // namespace esphome::light::testing
+91
View File
@@ -188,6 +188,8 @@ lvgl:
dark_mode: true
obj:
border_width: 1
user_1:
bg_color: black
gradients:
- id: color_bar
@@ -209,6 +211,63 @@ lvgl:
position: 212
- color: 0xFF0000
position: 255
- id: linear_grad
direction: LINEAR
linear:
from_x: 0%
from_y: 0%
to_x: 100%
to_y: 0%
extend: REFLECT
stops:
- color: 0xFF0000
position: 0
- color: 0x0000FF
position: 255
- id: radial_grad
direction: RADIAL
radial:
center_x: 50%
center_y: 50%
to_x: 100%
to_y: 50%
extend: PAD
stops:
- color: 0xFFFFFF
position: 0
- color: 0x000000
position: 255
- id: radial_focal_grad
direction: RADIAL
radial:
center_x: 50%
center_y: 50%
to_x: 100%
to_y: 50%
focal_x: 40%
focal_y: 40%
focal_radius: 10
extend: REPEAT
stops:
- color: 0xFF0000
position: 0
- color: 0x0000FF
position: 255
- id: conical_grad
direction: CONICAL
conical:
center_x: 50%
center_y: 50%
start_angle: 0
end_angle: 360
extend: PAD
stops:
- color: 0xFF0000
position: 0
- color: 0x00FF00
position: 127
- color: 0xFF0000
position: 255
style_definitions:
- id: style_test
@@ -660,6 +719,30 @@ lvgl:
id: button_with_text
text: Clicked
# Exercises the LV_STATE_USER_1..USER_4 states: setting them at creation
# (both literal and lambda), styling each of them individually, and
# setting/clearing them at runtime with lvgl.widget.update.
- button:
id: user_flags_button
text: User flags
state:
user_1: true
user_2: !lambda return true;
user_1:
bg_color: 0xFF00FF
user_2:
bg_color: 0x00FFFF
user_3:
bg_color: 0xFFFF00
user_4:
bg_color: 0x808080
on_click:
- lvgl.widget.update:
id: user_flags_button
state:
user_3: true
user_4: !lambda return !lv_obj_has_state(id(user_flags_button), LV_STATE_USER_4);
- button:
layout: 2x1
id: button_button
@@ -1070,6 +1153,14 @@ lvgl:
logger.log:
format: Slider released at %d/%d with value %.0f
args: ['(int) point.x', '(int) point.y', x]
# Exercises the style-application path for a complex gradient, not just its
# lv_grad_*_init() codegen: the other new gradients are only ever declared.
- obj:
bg_opa: cover
bg_grad: conical_grad
width: 40
height: 40
- button:
styles: spin_button
id: spin_up
+46
View File
@@ -0,0 +1,46 @@
mk2pvrouter:
id: test_mk2pvrouter
uart_id: uart_bus
sensor:
- platform: mk2pvrouter
name: Power
tag: P
mk2pvrouter_id: test_mk2pvrouter
unit_of_measurement: W
device_class: power
state_class: measurement
accuracy_decimals: 0
- platform: mk2pvrouter
name: Voltage
tag: V
mk2pvrouter_id: test_mk2pvrouter
unit_of_measurement: V
device_class: voltage
state_class: measurement
accuracy_decimals: 2
filters:
# Device sends voltage * 100
- multiply: 0.01
- platform: mk2pvrouter
name: Energy
tag: E
mk2pvrouter_id: test_mk2pvrouter
unit_of_measurement: Wh
device_class: energy
state_class: total_increasing
accuracy_decimals: 0
- platform: mk2pvrouter
name: Temperature
tag: T1
mk2pvrouter_id: test_mk2pvrouter
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
accuracy_decimals: 2
filters:
# Device sends temperature * 100
- multiply: 0.01
@@ -0,0 +1,3 @@
packages:
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml
mk2pvrouter: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml
mk2pvrouter: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml
mk2pvrouter: !include common.yaml
+8 -1
View File
@@ -11,7 +11,14 @@ 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); }
// 8N1, matching what the uart schema emits for a real hub; the framing drives the modbus
// interframe timing, so leaving data/stop bits at their zero defaults would not be representative.
NullUART() {
this->set_baud_rate(115200);
this->set_data_bits(8);
this->set_stop_bits(1);
this->set_parity(uart::UART_CONFIG_PARITY_NONE);
}
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; }
@@ -775,7 +775,7 @@ TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) {
// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be
// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently
// retiring it. Writes, 0x17, and custom codes still go through (covered above).
// retiring it. Writes and custom/unknown codes still go through (covered in the neighboring tests).
TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
@@ -814,9 +814,8 @@ TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
EXPECT_EQ(hub.entries(), 0u); // the entry is gone
}
// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks
// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling
// of an exception-flagged write.
// An exception-flagged code (0x80 bit set) is never a valid request - that bit is response-only - so
// queue_pdu refuses it up front, before the broadcast guard, whatever its base code.
TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
@@ -833,6 +832,50 @@ TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
EXPECT_EQ(device.sent_count_, 0); // never transmitted
}
// FC23 (read/write multiple) has a read half that expects a reply, so the Modbus spec does not allow it
// as a broadcast. is_function_code_read() covers it, so the broadcast guard refuses it despite its write
// half.
TEST(ModbusClientHubBroadcast, RefusesReadWriteMultipleBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
// fc, read start+qty, write start+qty, byte count, one data word.
const uint8_t read_write_multiple[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x01, 0x02, 0xBE, 0xEF};
EXPECT_FALSE(device.queue_pdu(read_write_multiple)); // its read half could never be answered
EXPECT_EQ(hub.entries(), 0u);
}
// FC 0x18 (read FIFO queue) is not a "read" by is_function_code_read(), but the hub has an explicit
// response-length rule for it - it demonstrably expects a reply, so it cannot broadcast.
TEST(ModbusClientHubBroadcast, RefusesKnownLengthNonWriteBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read_fifo[] = {0x18, 0x00, 0x10}; // fc, FIFO pointer address
EXPECT_FALSE(device.queue_pdu(read_fifo));
EXPECT_EQ(hub.entries(), 0u);
}
// A code that is neither a read nor exception-flagged (here 0x63, unassigned) is fire-and-forget on a
// broadcast: the hub can't know it isn't a vendor write, so it is accepted and delivered to all devices.
TEST(ModbusClientHubBroadcast, AcceptsNonReadUnknownBroadcast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t unknown[] = {0x63, 0x00, 0x01};
EXPECT_TRUE(device.queue_pdu(unknown)); // not a read, so not refused
EXPECT_EQ(hub.entries(), 1u);
}
namespace {
// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check.
class RejectPostDelayHub : public NoResponseProbeHub {
@@ -1882,30 +1925,20 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
}
// An exception-flagged function code is never silently re-sendable, even though the read check
// masks the exception bit: its duplicate takes the drop path like any other non-read.
TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) {
// The exception bit marks a response, so a request carrying it is refused outright.
TEST(ModbusClientHubPriority, ExceptionFlaggedPduRefused) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged
EXPECT_TRUE(device.queue_pdu(weird));
EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused
// The 0x80 exception flag is a response-only bit; a request must never set it. queue_pdu refuses an
// exception-flagged PDU up front - nothing is queued - whether its base code reads (0x83 = 0x03 | 0x80)
// or writes (0x86 = 0x06 | 0x80).
const uint8_t read_shaped[] = {0x83, 0x01, 0x00, 0x00, 0x02};
const uint8_t write_shaped[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
EXPECT_FALSE(device.queue_pdu(read_shaped));
EXPECT_FALSE(device.queue_pdu(write_shaped));
hub.sweep_for_test();
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).pending, 1u);
EXPECT_EQ(device.not_sent_count_, 0);
// The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class
// ordering either: exception-flagged codes are excluded from the mutates classification.
const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
device.queue_pdu(weird_write);
ASSERT_EQ(hub.queued_frames(), 2u);
EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE
const ModbusDeviceCommand *next = hub.next_ready();
ASSERT_NE(next, nullptr);
EXPECT_EQ(next->frame.pdu()[0], 0x83); // FIFO by age: it did not jump the older entry
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
@@ -0,0 +1,64 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Exposes the timing values setup() derives from the UART framing.
class FramingProbeHub : public ModbusClientHub {
public:
uint32_t bits_per_char() const { return this->bits_per_char_; }
uint32_t frame_delay_us() const { return this->frame_delay_us_; }
};
class FramedUART : public NullUART {
public:
FramedUART(uint32_t baud_rate, uint8_t data_bits, uint8_t stop_bits, uart::UARTParityOptions parity) {
this->set_baud_rate(baud_rate);
this->set_data_bits(data_bits);
this->set_stop_bits(stop_bits);
this->set_parity(parity);
}
};
} // namespace
// 8N1 is 10 bits on the wire, so t3.5 at 9600 baud is 3.5 * 10 / 9600 = 3645.8us.
TEST(ModbusFraming, EightNoneOneDerivesTenBits) {
FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_NONE);
FramingProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
EXPECT_EQ(hub.bits_per_char(), 10u);
EXPECT_EQ(hub.frame_delay_us(), 3646u);
}
// Spec-conformant RTU framing is 11 bits, which lengthens the interframe gap to
// 3.5 * 11 / 9600 = 4010.4us, rounded up.
TEST(ModbusFraming, EightEvenOneDerivesElevenBits) {
FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_EVEN);
FramingProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
EXPECT_EQ(hub.bits_per_char(), 11u);
EXPECT_EQ(hub.frame_delay_us(), 4011u);
}
// Above 19200 baud the spec's fixed 1750us floor governs instead of 3.5 characters.
TEST(ModbusFraming, FastBaudUsesSpecFloor) {
FramedUART uart(115200, 8, 1, uart::UART_CONFIG_PARITY_NONE);
FramingProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
EXPECT_EQ(hub.frame_delay_us(), 1750u);
}
} // namespace esphome::modbus::testing
@@ -63,6 +63,12 @@ TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusClientFrameLength, ExceptionFlaggedIsTheExceptionShape) {
// Sized at 2 so an exception-flagged request fails its CRC at once instead of being scanned for.
const uint8_t exception_request[] = {0x83, 0x02};
EXPECT_EQ(client_pdu_length(exception_request, sizeof(exception_request)), 2);
}
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
// basic_register request fixture is a read-holding request -> 8 bytes
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
@@ -421,11 +427,132 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
}
}
TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumberForQwords) {
// The word shuffle the QWORD_R decode replaces is the least obvious code in the byte path, so pin
// it against that path rather than against registers_to_value(). The top bit is set, which is where
// U_QWORD's unsigned value and this function's int64_t return deliberately diverge.
const uint16_t registers[] = {0xF123, 0x4567, 0x89AB, 0xCDEF};
const std::vector<uint8_t> bytes{0xF1, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF};
for (auto value_type :
{SensorValueType::U_QWORD, SensorValueType::S_QWORD, SensorValueType::U_QWORD_R, SensorValueType::S_QWORD_R}) {
EXPECT_EQ(registers_to_number(registers, 4, value_type),
payload_to_number(std::span<const uint8_t>(bytes), value_type, 0, 0xFFFFFFFF))
<< "value_type=" << static_cast<int>(value_type);
}
}
TEST(ModbusHelpersTest, RegistersToNumberTreatsRawAndBitAsNothingToDecode) {
// Both have no fixed-width number, so they decode to 0 whatever the span holds - including none.
const uint16_t registers[] = {0x1234};
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::RAW), std::optional<int64_t>(0));
EXPECT_EQ(registers_to_number(registers, 0, SensorValueType::RAW), std::optional<int64_t>(0));
EXPECT_EQ(registers_to_number(registers, 0, SensorValueType::BIT), std::optional<int64_t>(0));
}
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
const uint16_t registers[] = {0x1234};
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
}
// --- registers_to_value ----------------------------------------------------
// registers_to_number() dispatches to registers_to_value(), so this checks the dispatch table picks
// the right specialisation for each type, not that two implementations agree. The independent check
// against the byte decoder is RegistersToNumberMatchesPayloadToNumber below.
template<SensorValueType VALUE_TYPE> void expect_matches_registers_to_number(const uint16_t *registers) {
const auto expected = registers_to_number(registers, register_width_for(VALUE_TYPE), VALUE_TYPE);
// Plain control flow rather than ASSERT_TRUE: the optional analysis does not see through the macro.
if (!expected.has_value()) {
ADD_FAILURE() << "registers_to_number() returned no value for value_type=" << static_cast<int>(VALUE_TYPE);
return;
}
const int64_t number = expected.value();
if constexpr (VALUE_TYPE == SensorValueType::FP32 || VALUE_TYPE == SensorValueType::FP32_R) {
EXPECT_FLOAT_EQ(registers_to_value<VALUE_TYPE>(registers), bit_cast<float>(static_cast<uint32_t>(number)))
<< "value_type=" << static_cast<int>(VALUE_TYPE);
} else {
EXPECT_EQ(static_cast<int64_t>(registers_to_value<VALUE_TYPE>(registers)), number)
<< "value_type=" << static_cast<int>(VALUE_TYPE);
}
}
TEST(ModbusHelpersTest, RegistersToValueMatchesRegistersToNumber) {
// A high bit in each word exercises sign handling and word order together.
const uint16_t registers[] = {0x8001, 0xFE02};
expect_matches_registers_to_number<SensorValueType::U_WORD>(registers);
expect_matches_registers_to_number<SensorValueType::S_WORD>(registers);
expect_matches_registers_to_number<SensorValueType::U_WORD_S>(registers);
expect_matches_registers_to_number<SensorValueType::S_WORD_S>(registers);
expect_matches_registers_to_number<SensorValueType::U_DWORD>(registers);
expect_matches_registers_to_number<SensorValueType::U_DWORD_R>(registers);
expect_matches_registers_to_number<SensorValueType::S_DWORD>(registers);
expect_matches_registers_to_number<SensorValueType::S_DWORD_R>(registers);
expect_matches_registers_to_number<SensorValueType::FP32>(registers);
expect_matches_registers_to_number<SensorValueType::FP32_R>(registers);
}
TEST(ModbusHelpersTest, RegistersToUint32CombinesWordsHighFirst) {
EXPECT_EQ(registers_to_uint32(0x1234, 0x5678), 0x12345678u);
}
// --- value_at ---------------------------------------------------------------
// Addresses are absolute; anything not wholly inside the response yields nullopt.
TEST(ModbusHelpersTest, ValueAtDecodesByAbsoluteAddress) {
const uint16_t registers[] = {0x1111, 0x2222, 0x3333};
const std::span<const uint16_t> span(registers, 3);
EXPECT_EQ(value_at<SensorValueType::U_WORD>(span, 100, 100), std::optional<uint16_t>(0x1111));
EXPECT_EQ(value_at<SensorValueType::U_WORD>(span, 100, 102), std::optional<uint16_t>(0x3333));
EXPECT_EQ(value_at<SensorValueType::U_DWORD>(span, 100, 101), std::optional<uint32_t>(0x22223333u));
// Types whose RegisterValueType<> is not an unsigned integer, and the widest bounds check.
const uint16_t floats[] = {0x4048, 0xF5C3, 0xF5C3, 0x4048};
const std::span<const uint16_t> float_span(floats, 4);
EXPECT_FLOAT_EQ(value_at<SensorValueType::FP32>(float_span, 10, 10).value_or(0.0f), 3.14f);
EXPECT_FLOAT_EQ(value_at<SensorValueType::FP32_R>(float_span, 10, 12).value_or(0.0f), 3.14f);
EXPECT_EQ(value_at<SensorValueType::U_QWORD>(float_span, 10, 10), std::optional<uint64_t>(0x4048F5C3F5C34048ULL));
EXPECT_FALSE(value_at<SensorValueType::U_QWORD>(float_span, 10, 11).has_value());
}
TEST(ModbusHelpersTest, ValueAtIsUsableInAConstantExpression) {
static constexpr uint16_t REGISTERS[] = {0x1234, 0x5678};
static_assert(value_at<SensorValueType::U_DWORD>(REGISTERS, 7, 7).value_or(0) == 0x12345678u);
static_assert(!value_at<SensorValueType::U_DWORD>(REGISTERS, 7, 6).has_value());
}
TEST(ModbusHelpersTest, ValueAtRejectsAddressesOutsideTheResponse) {
const uint16_t registers[] = {0x1111, 0x2222, 0x3333};
const std::span<const uint16_t> span(registers, 3);
// Below the response: must not wrap when the subtraction would go negative.
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(span, 100, 99).has_value());
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(span, 100, 0).has_value());
// Past the end, and a multi-register value truncated by the end of the response.
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(span, 100, 103).has_value());
EXPECT_FALSE(value_at<SensorValueType::U_DWORD>(span, 100, 102).has_value());
EXPECT_TRUE(value_at<SensorValueType::U_DWORD>(span, 100, 101).has_value());
}
TEST(ModbusHelpersTest, ValueAtHandlesAnEmptyResponse) {
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(std::span<const uint16_t>(), 0, 0).has_value());
}
// --- QWORD decoding ---------------------------------------------------------
TEST(ModbusHelpersTest, RegistersToValueDecodesQwordBothWordOrders) {
const uint16_t registers[] = {0x0123, 0x4567, 0x89AB, 0xCDEF};
EXPECT_EQ(registers_to_value<SensorValueType::U_QWORD>(registers), 0x0123456789ABCDEFULL);
const uint16_t reversed[] = {0xCDEF, 0x89AB, 0x4567, 0x0123};
EXPECT_EQ(registers_to_value<SensorValueType::U_QWORD_R>(reversed), 0x0123456789ABCDEFULL);
// Signed reading of the same bits, and the sign-extreme case.
EXPECT_EQ(registers_to_value<SensorValueType::S_QWORD>(registers), 0x0123456789ABCDEFLL);
const uint16_t negative[] = {0xFFFF, 0xFFFF, 0xFFFF, 0xFFFE};
EXPECT_EQ(registers_to_value<SensorValueType::S_QWORD>(negative), -2);
EXPECT_EQ(registers_to_value<SensorValueType::U_QWORD>(negative), 0xFFFFFFFFFFFFFFFEULL);
}
TEST(ModbusHelpersTest, RegistersToUint64CombinesWordsHighFirst) {
EXPECT_EQ(registers_to_uint64(0x0123, 0x4567, 0x89AB, 0xCDEF), 0x0123456789ABCDEFULL);
}
// --- packed bit helpers ------------------------------------------------------
TEST(ModbusHelpersTest, PackBitsAppendsToContainer) {
@@ -483,6 +610,28 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
}
TEST(ModbusTypedBuilders, WriteFewRegistersPduMatchesFullSizeBuilder) {
static_assert(sizeof(WriteFewRegistersPdu) < sizeof(PduBuffer) / 4,
"WriteFewRegistersPdu must be meaningfully smaller");
const uint16_t values[] = {0x000B, 0x0016, 0xABCD, 0xFF00};
for (size_t count = 1; count <= MAX_FEW_REGISTERS; count++) {
auto small = create_write_few_registers_pdu(0x0102, std::span<const uint16_t>(values, count));
auto full = create_write_registers_pdu(0x0102, std::span<const uint16_t>(values, count));
EXPECT_EQ(std::vector<uint8_t>(small.begin(), small.end()), std::vector<uint8_t>(full.begin(), full.end()))
<< count << " registers";
EXPECT_EQ(small.size(), 6u + 2 * count);
EXPECT_TRUE(is_client_pdu_standard(small.data(), small.size()));
}
}
TEST(ModbusTypedBuilders, WriteFewRegistersPduRejectsInvalidInput) {
const uint16_t values[MAX_FEW_REGISTERS + 1] = {0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA};
EXPECT_TRUE(create_write_few_registers_pdu(0x0000, values).empty());
EXPECT_FALSE(create_write_few_registers_pdu(0x0000, std::span<const uint16_t>(values, MAX_FEW_REGISTERS)).empty());
EXPECT_TRUE(create_write_few_registers_pdu(0x0000, std::span<const uint16_t>()).empty());
EXPECT_TRUE(create_write_few_registers_pdu(0xFFFF, std::span<const uint16_t>(values, 2)).empty());
}
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) {
const uint16_t write_values[] = {0x000B, 0x0016};
// Read 2 registers at 0x0010, write 2 registers at 0x0020.
@@ -54,7 +54,8 @@ class TestServerHub : public ModbusServerHub {
// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the
// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes -
// must classify as unknown length. The exception flag masks off first.
// must classify as unknown length. Exception replies are always the 2-byte spec shape, so every
// 0x80-set code is known length.
TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) {
EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
@@ -62,11 +63,13 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) {
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
}
// Exception replies classify by their base code.
// Every exception-flagged code is known length (the 2-byte spec exception shape), whatever its base.
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83));
EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87));
// Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa.
for (int fc = 0; fc <= 0xFF; fc++) {
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x87));
EXPECT_FALSE(helpers::is_function_code_unknown_length(0xC9));
// Strictly wider than the user-defined ranges below 0x80: every non-exception custom code is
// unknown-length, but not vice versa.
for (int fc = 0; fc <= 0x7F; fc++) {
if (helpers::is_function_code_custom(fc))
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc;
}
@@ -75,10 +78,10 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
// Derived contract check: the helper must say "unknown" exactly when both length parsers fall
// through to default. With a zero-filled max-size PDU every explicit case returns at least 2
// (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The
// loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length()
// switches on the unmasked byte and server_pdu_length() early-returns the exception length.
for (int fc = 0; fc <= 0x7F; fc++) {
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. Both
// parsers early-return the 2-byte exception shape above 0x7F, which the helper's own exception
// early-return mirrors, so the whole byte range is covered.
for (int fc = 0; fc <= 0xFF; fc++) {
const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
@@ -89,6 +92,17 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
}
}
// Broadcastable = writes plus unknown codes (possible vendor writes); everything known to expect a
// reply is not. Classifies the underlying code: the exception bit masks off first (0x85 as 0x05).
TEST(ModbusUnknownFunction, BroadcastableClassification) {
for (uint8_t fc : {0x05, 0x06, 0x0F, 0x10, 0x16, 0x49, 0x63, 0x6E, 0x85, 0xC9}) {
EXPECT_TRUE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
}
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18, 0x83, 0x97}) {
EXPECT_FALSE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
}
}
// A response with a function code outside the user-defined ranges (0x49) has no length case in
// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already
// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the
@@ -5,6 +5,11 @@
#include "esphome/components/modbus_controller/modbus_controller.h"
// These tests pin the behaviour of the deprecated ModbusCommandItem until its removal.
// Remove with ModbusCommandItem before 2027.3.0.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
namespace esphome::modbus_controller::testing {
// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum
@@ -29,3 +34,5 @@ TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
}
} // namespace esphome::modbus_controller::testing
#pragma GCC diagnostic pop
+11 -7
View File
@@ -21,6 +21,7 @@ binary_sensor:
name: Test Binary Sensor with Lambda
register_type: input
address: 0x3201
reuse_previous_range: false
lambda: |-
return x;
@@ -85,6 +86,7 @@ select:
name: Test Select with Lambda
address: 1001
value_type: U_WORD
reuse_previous_range: auto
optionsmap:
"Off": 0
"On": 1
@@ -140,9 +142,10 @@ sensor:
register_type: holding
address: 0x9002
value_type: U_WORD
reuse_previous_range: true
lambda: |-
return x / 10.0;
# Non-mergeable sensor sharing the start address of modbus_sensor1 (different register_count):
# Non-mergeable sensor sharing the start address of modbus_sensor1 (different value type width):
# must join the same range, never open a second range keyed on the same (address, type).
- platform: modbus_controller
modbus_controller_id: modbus_controller1
@@ -187,8 +190,8 @@ sensor:
value_type: U_WORD
lambda: |-
return modbus_controller::get_data<uint16_t>(data, item->offset) * 0.1f;
# force_new_range sensors sort before plain ones, so this high-address forced sensor is grouped
# first and the lower-address plain sensors above must still get their own ranges.
# The deprecated force_new_range migrates to reuse_previous_range: false, so this sensor never
# joins a range built before it and the lower-address sensors above keep their own ranges.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_forced_high
@@ -224,7 +227,6 @@ text_sensor:
name: Test Text Sensor
register_type: holding
address: 0x9013
register_count: 3
raw_encode: HEXBYTES
response_size: 6
- platform: modbus_controller
@@ -233,12 +235,13 @@ text_sensor:
name: Test Text Sensor with Lambda
register_type: holding
address: 0x9014
register_count: 2
response_size: 4
lambda: |-
return "Modified: " + x;
# A register reporting FEWER bytes than 2*register_count (response_size: 3 for 2 registers), followed
# by a contiguous sensor: the follower's byte position must track the actual 3 bytes, not underflow.
# A register reporting FEWER bytes than two per register (response_size: 3 over 2 registers), followed
# by a contiguous reuse:true sensor (auto never joins past a response_size register): the follower's
# byte position must track the actual 3 bytes, not underflow.
# register_count matches the derived width, so it migrates with a deprecation warning.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_text_sensor_narrow
@@ -255,4 +258,5 @@ text_sensor:
register_type: holding
address: 0x9032
register_count: 1
reuse_previous_range: true
raw_encode: HEXBYTES
@@ -62,6 +62,12 @@ image:
url: http://www.faqs.org/images/library.jpg
format: AUTO
type: RGB565
- platform: online_image
id: online_qoi_image
url: https://www.example.org/image.qoi
format: QOI
type: RGB
transparency: alpha_channel
# Check the set_url action
esphome:
+9
View File
@@ -0,0 +1,9 @@
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
port: 3288
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
@@ -0,0 +1,12 @@
wifi:
ssid: MySSID
password: password1
api:
encryption:
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
ota:
- platform: esphome
port: 3289
encryption:
@@ -0,0 +1,2 @@
packages:
ota: !include encryption.yaml
@@ -0,0 +1,2 @@
packages:
ota: !include encryption.yaml
@@ -0,0 +1,2 @@
packages:
ota: !include encryption.yaml
@@ -0,0 +1,2 @@
packages:
ota: !include encryption_inherit.yaml
@@ -1,6 +1,7 @@
# Exercises the provisioning window: api registers as a provisioning source
# (encryption enabled, no key), the on_timeout automation, and the wifi +
# esp32_improv cross-component guards. improv_serial is intentionally NOT gated.
# (encryption enabled, no key), the on_timeout automation, and the wifi (AP +
# captive portal) and esp32_improv cross-component guards. improv_serial is
# intentionally NOT gated.
provisioning:
timeout: 1min
on_timeout:
@@ -13,6 +14,10 @@ api:
wifi:
ssid: MySSID
password: password1
ap:
ssid: MyAP
captive_portal:
improv_serial:
@@ -1,5 +1,6 @@
# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source
# and the wifi reboot guard. improv_serial is present and intentionally NOT gated.
# and the wifi (AP + captive portal) guards. improv_serial is present and
# intentionally NOT gated.
provisioning:
timeout: 1min
on_timeout:
@@ -12,5 +13,9 @@ api:
wifi:
ssid: MySSID
password: password1
ap:
ssid: MyAP
captive_portal:
improv_serial:
+2 -1
View File
@@ -9,7 +9,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# tests have two decoder types and every retained decoder is under test.
async def to_code_testing(config: ConfigType) -> None:
enable_format("BMP")
enable_format("PNG")
enable_format("JPEG")
enable_format("PNG")
enable_format("QOI")
manifest.to_code = to_code_testing
@@ -70,11 +70,36 @@ static const uint8_t PNG_RGB_EXPECTED[4][4][3] = {
{{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}},
};
// 3x3 QOI, exercising all possible chunk types
static const uint8_t QOI_RGBA[] = {
0x71, 0x6F, 0x69, 0x66, // Header: 'qoif'
0x00, 0x00, 0x00, 0x03, // Width: 3
0x00, 0x00, 0x00, 0x03, // Height: 3
0x04, // Channels: 4 (RGBA)
0x00, // Colorspace: 0 (SRGB)
0xC1, // 1. QOI_OP_RUN
0x79, // 2. QOI_OP_DIFF
0xAA, 0x79, // 3. QOI_OP_LUMA
0xFE, 0xC8, 0x64, 0x32, // 4. QOI_OP_RGB
0xFF, 0x78, 0x50, 0x28,
0x64, // 5. QOI_OP_RGBA
0x31, // 6. QOI_OP_INDEX
0xC1, // 7. QOI_OP_RUN
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // End Marker
};
static const uint8_t QOI_EXPECTED_RGBA[3][3][4] = {
{{0x00, 0x00, 0x00, 0xFF}, {0x00, 0x00, 0x00, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}},
{{0x0A, 0x0A, 0x0A, 0xFF}, {0xC8, 0x64, 0x32, 0xFF}, {0x78, 0x50, 0x28, 0x64}},
{{0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}}
};
/// Exposes the protected decoder machinery so reuse and eviction can be observed directly.
class TestableRuntimeImage : public RuntimeImage {
public:
explicit TestableRuntimeImage(ImageFormat format)
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE)
: RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {}
ImageDecoder *decoder() { return this->decoder_.get(); }
};
@@ -132,6 +157,19 @@ template<size_t H, size_t W> static void expect_pixels(TestableRuntimeImage &img
}
}
template<size_t H, size_t W>
static void expect_pixels_rgba(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][4]) {
ASSERT_EQ(img.get_width(), static_cast<int>(W));
ASSERT_EQ(img.get_height(), static_cast<int>(H));
for (size_t y = 0; y < H; y++) {
for (size_t x = 0; x < W; x++) {
SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")");
Color color = img.get_pixel(x, y);
EXPECT_THAT((std::array<uint8_t, 4>{color.r, color.g, color.b, color.w}),
::testing::ElementsAreArray(expected[y][x]));
}
}
}
TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) {
TestableRuntimeImage img(BMP);
@@ -337,6 +375,33 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
}
#endif // USE_RUNTIME_IMAGE_JPEG
TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) {
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
ImageDecoder *first = img.decoder();
ASSERT_NE(first, nullptr);
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated";
}
TEST(RuntimeImageDecoder, QoiChunkedFeedDecodesLikeDownloadLoop) {
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
ImageDecoder *first = img.decoder();
// Chunked again on the warm decoder: the cross-call resume state
// (current_index_ / paint_index_) must have been fully reset.
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
EXPECT_EQ(img.decoder(), first);
}
TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
TestableRuntimeImage img(BMP);
std::vector<uint8_t> buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
@@ -10,12 +10,14 @@ TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
#endif // USE_RUNTIME_IMAGE_JPEG
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/qoi"), QOI);
EXPECT_EQ(get_format_for_mime_type("image/x-qoi"), QOI);
}
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
@@ -39,20 +41,22 @@ TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
#endif // USE_RUNTIME_IMAGE_JPEG
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
EXPECT_STREQ(get_mime_type_for_format(QOI), "image/qoi");
// AUTO has no single MIME type and falls back to the wildcard
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
// Every decodable format must resolve back to itself through its MIME type
for (ImageFormat format : {
BMP,
PNG,
#ifdef USE_RUNTIME_IMAGE_JPEG
JPEG,
#endif // USE_RUNTIME_IMAGE_JPEG
PNG,
QOI,
}) {
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
}
+13
View File
@@ -28,8 +28,21 @@ sensor:
accuracy_decimals: 1
nox_index:
name: NOx Index
algorithm_tuning:
index_offset: 8
learning_time_offset_hours: 6
learning_time_gain_hours: 24
gating_max_duration_minutes: 900
gain_factor: 180
voc_index:
name: VOC Index
algorithm_tuning:
index_offset: 120
learning_time_offset_hours: 6
learning_time_gain_hours: 24
gating_max_duration_minutes: 240
std_initial: 75
gain_factor: 180
co2:
name: Carbon Dioxide
formaldehyde:
@@ -0,0 +1,18 @@
# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the
# keys that are left out.
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
sensor:
- platform: sen6x
id: sen6x_partial_tuning
type: SEN65
i2c_id: i2c_bus
voc_index:
name: VOC Index
algorithm_tuning:
index_offset: 60
nox_index:
name: NOx Index
algorithm_tuning:
gain_factor: 45
@@ -0,0 +1,10 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
common: !include common.yaml
psram:
mode: octal
spi_device:
- id: spi_device_psram_dma_test
psram_dma: true
data_rate: 1MHz
spi_mode: 0
+21
View File
@@ -80,3 +80,24 @@ switch:
- platform: tuya
id: tuya_switch
switch_datapoint: 1
water_heater:
- platform: tuya
id: tuya_water_heater
name: Tuya Water Heater
switch_datapoint: 1
current_temperature_datapoint: 3
target_temperature_datapoint: 2
current_temperature_multiplier: 0.5
target_temperature_multiplier: 0.5
mode_datapoint: 4
eco_value: 0
electric_value: 2
supported_modes:
- "OFF"
- ECO
- ELECTRIC
visual:
min_temperature: 30
max_temperature: 75
target_temperature_step: 1