diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 6423b61f64..3aef47bb47 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -23,7 +23,7 @@ namespace esphome { void HOT yield() { vPortYield(); } uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } -uint64_t HOT millis_64() { return static_cast(esp_timer_get_time()) / 1000ULL; } +uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index a4ec17608d..01949144cc 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -11,7 +11,7 @@ namespace esphome { void HOT yield() { ::yield(); } -uint64_t millis_64() { return time_us_64() / 1000ULL; } +uint64_t millis_64() { return micros_to_millis(time_us_64()); } uint32_t HOT millis() { return micros_to_millis(time_us_64()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t HOT micros() { return ::micros(); } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index afcb1a65d7..f423b84392 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -599,8 +599,12 @@ template constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } -/// Convert a 64-bit microsecond count to a 32-bit millisecond count without -/// calling __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// Convert a 64-bit microsecond count to milliseconds without calling +/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// +/// Returns uint32_t by default (for millis()), or uint64_t when requested +/// (for millis_64()). The only difference is whether hi * Q is truncated +/// to 32 bits or widened to 64. /// /// On 32-bit targets, GCC does not optimize 64-bit constant division into a /// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 @@ -618,7 +622,7 @@ inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str /// /// See: https://en.wikipedia.org/wiki/Euclidean_division /// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html -inline constexpr ESPHOME_ALWAYS_INLINE uint32_t micros_to_millis(uint64_t us) { +template inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) { constexpr uint32_t D = 125U; constexpr uint32_t Q = static_cast((1ULL << 32) / D); // 34359738 constexpr uint32_t R = static_cast((1ULL << 32) % D); // 46 @@ -629,7 +633,8 @@ inline constexpr ESPHOME_ALWAYS_INLINE uint32_t micros_to_millis(uint64_t us) { // Combine remainder term: hi * (2^32 % 125) + lo uint32_t adj = hi * R + lo; // If adj overflowed, the true value is 2^32 + adj; apply the identity again - return hi * Q + (adj < lo ? (adj + R) / D + Q : adj / D); + // 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); } /// Return a random 32-bit unsigned integer. diff --git a/tests/unit_tests/test_micros_to_millis.py b/tests/unit_tests/test_micros_to_millis.py index 5196185b0c..6659959b87 100644 --- a/tests/unit_tests/test_micros_to_millis.py +++ b/tests/unit_tests/test_micros_to_millis.py @@ -2,7 +2,7 @@ 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. +edge cases and overflow boundaries. Tests both 32-bit and 64-bit variants. """ import pytest @@ -12,10 +12,11 @@ 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 milliseconds using Euclidean decomposition.""" + """Convert microseconds to 32-bit milliseconds using Euclidean decomposition.""" x = us >> 3 lo = x & UINT32_MAX hi = (x >> 32) & UINT32_MAX @@ -25,92 +26,150 @@ def micros_to_millis(us: int) -> int: return (hi * Q + adj // D) & UINT32_MAX -def reference(us: int) -> int: - """Reference implementation: truncated 32-bit result of us / 1000.""" +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 -@pytest.mark.parametrize( - "us", - [ - 0, - 1, - 999, - 1000, - 1001, - 7999, - 8000, - 8001, - 999_999, - 1_000_000, - UINT32_MAX - 1, - UINT32_MAX, - UINT32_MAX + 1, - ], - ids=lambda v: f"us={v}", -) -def test_small_and_boundary_values(us): - assert micros_to_millis(us) == reference(us) +def reference_64(us: int) -> int: + """Reference: 64-bit result of us / 1000.""" + return us // 1000 -@pytest.mark.parametrize( - "hi", - [1, 2, 100, 603, 1000, 5000, 10000, 14685, 0xFFFF], - ids=lambda v: f"hi={v}", -) -@pytest.mark.parametrize( - "lo_offset", - [0, 1, 999, UINT32_MAX - 999, UINT32_MAX], - ids=lambda v: f"lo={v}", -) -def test_hi_lo_combinations(hi, lo_offset): +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): + 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, lo_offset): us = (hi << 32) | lo_offset - assert micros_to_millis(us) == reference(us) + 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_carry_boundary(hi): +@pytest.mark.parametrize("hi", [1, 50, 100, 500, 1000, 5000], ids=lambda v: f"hi={v}") +def test_32bit_carry_boundary(hi): """Test around the adj overflow boundary (hi * R + lo > UINT32_MAX).""" - # After >>3, the decomposition uses R=46 - # Carry boundary for original us: compute where adj wraps - # hi_shifted = (us >> 3) >> 32 = us >> 35 - # We construct us such that hi_shifted = hi base = hi << 35 hi_r = hi * R if hi_r < UINT32_MAX: threshold_lo = UINT32_MAX - hi_r - # Test around the boundary in the shifted domain for lo in [threshold_lo - 1, threshold_lo, threshold_lo + 1]: - us = base | (lo << 3) # Scale lo back to us domain - assert micros_to_millis(us) == reference(us) + us = base | (lo << 3) + assert micros_to_millis(us) == reference_32(us) @pytest.mark.parametrize( - "us", - [ - # 30-day uptime in microseconds - 2_592_000_000_000, - # 1-year uptime - 31_536_000_000_000, - # Near safe limit (~101,700 years) - 3_200_000_000_000_000_000, - ], - ids=["30_days", "1_year", "near_safe_limit"], + "us", UPTIME_VALUES, ids=["30_days", "1_year", "near_safe_limit"] ) -def test_realistic_uptimes(us): - assert micros_to_millis(us) == reference(us) +def test_32bit_realistic_uptimes(us): + assert micros_to_millis(us) == reference_32(us) -def test_shift_boundary_mod8(): +def test_32bit_shift_boundary_mod8(): """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(us) + 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): + 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, lo_offset): + 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): + """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): + assert micros_to_millis_64(us) == reference_64(us) + + +def test_64bit_shift_boundary_mod8(): + """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(): + """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(): @@ -125,4 +184,13 @@ def test_constexpr_values(): assert micros_to_millis(0) == 0 assert micros_to_millis(999) == 0 assert micros_to_millis(1000) == 1 - assert micros_to_millis(2_592_000_000_000) == 2_592_000_000 + 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(): + """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)