diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3696e15f68..9ed13a1dc2 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -639,10 +639,6 @@ template inline constexpr ESPHOME_ALWAYS_INLINE Ret // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); } -static_assert(micros_to_millis(0) == 0); -static_assert(micros_to_millis(999) == 0); -static_assert(micros_to_millis(1000) == 1); -static_assert(micros_to_millis(2592000000000ULL) == 2592000000U); // 30 days /// Return a random 32-bit unsigned integer. uint32_t random_uint32(); diff --git a/tests/integration/fixtures/micros_to_millis.yaml b/tests/integration/fixtures/micros_to_millis.yaml new file mode 100644 index 0000000000..d11808c43a --- /dev/null +++ b/tests/integration/fixtures/micros_to_millis.yaml @@ -0,0 +1,61 @@ +esphome: + name: micros-to-millis-test + platformio_options: + build_flags: + - "-DDEBUG" + on_boot: + - lambda: |- + using esphome::micros_to_millis; + const char *TAG = "MTM"; + int pass = 0, fail = 0; + + auto check = [&](const char *name, uint64_t us) { + uint32_t got = micros_to_millis(us); + uint32_t want = (uint32_t)(us / 1000ULL); + if (got == want) { pass++; } + else { ESP_LOGE(TAG, "%s FAILED: got=%u want=%u", name, got, want); fail++; } + }; + + // Basic values + check("zero", 0); + check("below_1ms", 999); + check("exactly_1ms", 1000); + check("above_1ms", 1001); + + // Shift boundary (1000 = 8 * 125, exercises the >>3 shift) + check("shift_7999", 7999); + check("shift_8000", 8000); + check("shift_8001", 8001); + + // 32-bit boundary + check("u32max_minus1", 0xFFFFFFFEULL); + check("u32max", 0xFFFFFFFFULL); + check("u32max_plus1", 0x100000000ULL); + + // Realistic uptimes + check("30_days", 2592000000000ULL); + check("1_year", 31536000000000ULL); + + // Carry path: construct x = us>>3 with specific hi/lo that trigger adj overflow + { uint64_t x = (603ULL << 32) | 0xFFFFFFFFU; check("carry_603", x << 3); } + { uint64_t x = (5000ULL << 32) | 0xFFFFFFFFU; check("carry_5000", x << 3); } + + // Carry boundary: exact transition where adj overflows (hi=1000, R=46) + { + uint32_t hi = 1000; + uint32_t thr = 0xFFFFFFFFU - hi * 46U; + uint64_t h = (uint64_t)hi << 32; + check("carry_before", (h | (thr - 1)) << 3); + check("carry_at", (h | thr) << 3); + check("carry_after", (h | (thr + 1)) << 3); + } + + // Mod-8 variations (exercises the >>3 truncation) + for (int i = 0; i < 8; i++) { check("mod8", 2592000000000ULL + i); } + + if (fail == 0) { ESP_LOGI(TAG, "ALL_PASSED %d tests", pass); } + else { ESP_LOGE(TAG, "%d FAILED out of %d", fail, pass + fail); } + +host: +api: +logger: diff --git a/tests/integration/test_micros_to_millis.py b/tests/integration/test_micros_to_millis.py new file mode 100644 index 0000000000..9960d6b017 --- /dev/null +++ b/tests/integration/test_micros_to_millis.py @@ -0,0 +1,46 @@ +"""Integration test for micros_to_millis Euclidean decomposition.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_micros_to_millis( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that micros_to_millis matches reference uint64 division.""" + + all_passed = asyncio.Event() + failures: list[str] = [] + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + if "ALL_PASSED" in clean_line: + all_passed.set() + elif "FAILED" in clean_line and "[MTM" in clean_line: + failures.append(clean_line) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "micros-to-millis-test" + + try: + await asyncio.wait_for(all_passed.wait(), timeout=2.0) + except TimeoutError: + if failures: + pytest.fail(f"micros_to_millis failures: {failures}") + pytest.fail("micros_to_millis test timed out") + + assert not failures, f"micros_to_millis failures: {failures}" diff --git a/tests/unit_tests/test_micros_to_millis.py b/tests/unit_tests/test_micros_to_millis.py deleted file mode 100644 index 7e0da9048b..0000000000 --- a/tests/unit_tests/test_micros_to_millis.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Tests for micros_to_millis Euclidean decomposition. - -Verifies that the Python equivalent of the C++ micros_to_millis() helper -in esphome/core/helpers.h matches the reference (us // 1000) across -edge cases and overflow boundaries. Tests both 32-bit and 64-bit variants. -""" - -import pytest - -# Constants matching the C++ implementation (shift3+div125 variant) -D = 125 -Q = (1 << 32) // D # 34359738 -R = (1 << 32) % D # 46 -UINT32_MAX = 0xFFFFFFFF -UINT64_MAX = 0xFFFFFFFFFFFFFFFF - - -def micros_to_millis(us: int) -> int: - """Convert microseconds to 32-bit milliseconds using Euclidean decomposition.""" - x = us >> 3 - lo = x & UINT32_MAX - hi = (x >> 32) & UINT32_MAX - adj = (hi * R + lo) & UINT32_MAX - if adj < lo: - return (hi * Q + (adj + R) // D + Q) & UINT32_MAX - return (hi * Q + adj // D) & UINT32_MAX - - -def micros_to_millis_64(us: int) -> int: - """Convert microseconds to 64-bit milliseconds using Euclidean decomposition.""" - x = us >> 3 - lo = x & UINT32_MAX - hi = (x >> 32) & UINT32_MAX - adj = (hi * R + lo) & UINT32_MAX - if adj < lo: - return (hi * Q + (adj + R) // D + Q) & UINT64_MAX - return (hi * Q + adj // D) & UINT64_MAX - - -def reference_32(us: int) -> int: - """Reference: truncated 32-bit result of us / 1000.""" - return (us // 1000) & UINT32_MAX - - -def reference_64(us: int) -> int: - """Reference: 64-bit result of us / 1000.""" - return us // 1000 - - -BOUNDARY_VALUES = [ - 0, - 1, - 999, - 1000, - 1001, - 7999, - 8000, - 8001, - 999_999, - 1_000_000, - UINT32_MAX - 1, - UINT32_MAX, - UINT32_MAX + 1, -] - -HI_VALUES = [1, 2, 100, 603, 1000, 5000, 10000, 14685, 0xFFFF] -LO_VALUES = [0, 1, 999, UINT32_MAX - 999, UINT32_MAX] -UPTIME_VALUES = [ - 2_592_000_000_000, # 30-day - 31_536_000_000_000, # 1-year - 3_200_000_000_000_000_000, # ~101,700 years (near safe limit) -] - - -# --- 32-bit tests --- - - -@pytest.mark.parametrize("us", BOUNDARY_VALUES, ids=lambda v: f"us={v}") -def test_32bit_boundary_values(us: int) -> None: - assert micros_to_millis(us) == reference_32(us) - - -@pytest.mark.parametrize("hi", HI_VALUES, ids=lambda v: f"hi={v}") -@pytest.mark.parametrize("lo_offset", LO_VALUES, ids=lambda v: f"lo={v}") -def test_32bit_hi_lo_combinations(hi: int, lo_offset: int) -> None: - us = (hi << 32) | lo_offset - assert micros_to_millis(us) == reference_32(us) - - -@pytest.mark.parametrize("hi", [1, 50, 100, 500, 1000, 5000], ids=lambda v: f"hi={v}") -def test_32bit_carry_boundary(hi: int) -> None: - """Test around the adj overflow boundary (hi * R + lo > UINT32_MAX).""" - base = hi << 35 - hi_r = hi * R - if hi_r < UINT32_MAX: - threshold_lo = UINT32_MAX - hi_r - for lo in [threshold_lo - 1, threshold_lo, threshold_lo + 1]: - us = base | (lo << 3) - assert micros_to_millis(us) == reference_32(us) - - -@pytest.mark.parametrize( - "us", UPTIME_VALUES, ids=["30_days", "1_year", "near_safe_limit"] -) -def test_32bit_realistic_uptimes(us: int) -> None: - assert micros_to_millis(us) == reference_32(us) - - -def test_32bit_shift_boundary_mod8() -> None: - """Values where us % 8 varies — exercises the >>3 shift edge.""" - for base in [0, 1000, 8000, UINT32_MAX, 603 << 32]: - for offset in range(8): - us = base + offset - assert micros_to_millis(us) == reference_32(us) - - -# --- 64-bit tests --- - - -@pytest.mark.parametrize("us", BOUNDARY_VALUES, ids=lambda v: f"us={v}") -def test_64bit_boundary_values(us: int) -> None: - assert micros_to_millis_64(us) == reference_64(us) - - -@pytest.mark.parametrize("hi", HI_VALUES, ids=lambda v: f"hi={v}") -@pytest.mark.parametrize("lo_offset", LO_VALUES, ids=lambda v: f"lo={v}") -def test_64bit_hi_lo_combinations(hi: int, lo_offset: int) -> None: - us = (hi << 32) | lo_offset - assert micros_to_millis_64(us) == reference_64(us) - - -@pytest.mark.parametrize("hi", [1, 50, 100, 500, 1000, 5000], ids=lambda v: f"hi={v}") -def test_64bit_carry_boundary(hi: int) -> None: - """Test around the adj overflow boundary for 64-bit result.""" - base = hi << 35 - hi_r = hi * R - if hi_r < UINT32_MAX: - threshold_lo = UINT32_MAX - hi_r - for lo in [threshold_lo - 1, threshold_lo, threshold_lo + 1]: - us = base | (lo << 3) - assert micros_to_millis_64(us) == reference_64(us) - - -@pytest.mark.parametrize( - "us", UPTIME_VALUES, ids=["30_days", "1_year", "near_safe_limit"] -) -def test_64bit_realistic_uptimes(us: int) -> None: - assert micros_to_millis_64(us) == reference_64(us) - - -def test_64bit_shift_boundary_mod8() -> None: - """Values where us % 8 varies — exercises the >>3 shift edge.""" - for base in [0, 1000, 8000, UINT32_MAX, 603 << 32]: - for offset in range(8): - us = base + offset - assert micros_to_millis_64(us) == reference_64(us) - - -def test_64bit_preserves_upper_bits() -> None: - """Verify 64-bit variant does not truncate large results.""" - # 30-day uptime: result > UINT32_MAX - us = 2_592_000_000_000 - result = micros_to_millis_64(us) - assert result == 2_592_000_000 - # 1-year uptime - us = 31_536_000_000_000 - result = micros_to_millis_64(us) - assert result == 31_536_000_000 - assert result > UINT32_MAX - - -# --- Shared tests --- - - -def test_constants_match() -> None: - """Verify the Euclidean decomposition constants are correct.""" - assert Q == 34359738 - assert R == 46 - assert Q * D + R == (1 << 32) - - -def test_constexpr_values() -> None: - """Values suitable for static_assert in C++ if made constexpr.""" - assert micros_to_millis(0) == 0 - assert micros_to_millis(999) == 0 - assert micros_to_millis(1000) == 1 - assert micros_to_millis_64(0) == 0 - assert micros_to_millis_64(999) == 0 - assert micros_to_millis_64(1000) == 1 - assert micros_to_millis_64(2_592_000_000_000) == 2_592_000_000 - - -def test_32bit_and_64bit_agree_when_result_fits() -> None: - """Both variants agree when result fits in 32 bits.""" - for us in [0, 1000, 999_999, UINT32_MAX]: - assert micros_to_millis(us) == micros_to_millis_64(us)