Merge branch 'esp8266-native-ninja-emission' into esp8266-arduino-toolchain

This commit is contained in:
J. Nick Koston
2026-08-28 20:41:31 -05:00
40 changed files with 1202 additions and 532 deletions
+3 -3
View File
@@ -328,10 +328,10 @@ TEST(StepToAccuracyDecimals, RoundsUpToWholeNumber) {
}
TEST(StepToAccuracyDecimals, OutsideFixedNotationRange) {
// %.5g prints these in exponent form, so the count comes from parsing "1e-05" or "1.2346e+05".
EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 0);
// %.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), 8);
EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 0);
EXPECT_EQ(step_to_accuracy_decimals(1000000.0f), 0);
}
@@ -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
+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
@@ -427,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) {
@@ -0,0 +1,14 @@
# Common UART configuration for ESP32 Arduino tests - 9600 baud, EVEN parity, 7 data bits
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 9600
parity: EVEN
data_bits: 7
stop_bits: 1
@@ -0,0 +1,14 @@
# Common UART configuration for ESP32 IDF tests - 9600 baud, EVEN parity, 7 data bits
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 9600
parity: EVEN
data_bits: 7
stop_bits: 1
@@ -0,0 +1,14 @@
# Common UART configuration for ESP8266 Arduino tests - 9600 baud even parity, 7 data bits
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 9600
parity: EVEN
data_bits: 7
stop_bits: 1
@@ -0,0 +1,14 @@
# Common UART configuration for RP2040 Arduino tests - 9600 baud even parity, 7 data bits
substitutions:
tx_pin: GPIO0
rx_pin: GPIO1
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 9600
parity: EVEN
data_bits: 7
stop_bits: 1
@@ -53,9 +53,12 @@ def test_nonzero_indices_are_nonzero(gamma: float) -> None:
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
@pytest.mark.parametrize("gamma", [1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0])
def test_table_monotonically_nondecreasing(gamma: float) -> None:
"""The gamma table must be monotonically non-decreasing."""
"""The gamma table must be monotonically non-decreasing.
gamma_table_reverse_search()'s binary search depends on this.
"""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= table[i - 1], (
@@ -115,3 +118,13 @@ def test_lut_output_monotonically_nondecreasing() -> None:
result = _simulate_gamma_correct_lut(table, value)
assert result >= prev, f"value={value}: result {result} < previous {prev}"
prev = result
def test_table_matches_raw_power_curve() -> None:
"""Check the gamma table against known good values for gamma=2.8."""
table = generate_gamma_table(2.8)
golden = {1: 1, 5: 1, 15: 24, 27: 122, 28: 135, 100: 4766, 200: 33193, 254: 64818}
for i, expected in golden.items():
assert table[i] == expected, (
f"index {i}: table[{i}]={table[i]} expected {expected}"
)