mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 17:05:36 +00:00
Merge remote-tracking branch 'upstream/esp32-millis-euclidean-decomposition' into integration
This commit is contained in:
@@ -639,10 +639,6 @@ template<typename ReturnT = uint32_t> inline constexpr ESPHOME_ALWAYS_INLINE Ret
|
||||
// static_cast<ReturnT>(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q
|
||||
return static_cast<ReturnT>(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();
|
||||
|
||||
@@ -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:
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user