Skip to content

[sprinkler] Fix millis overflow and underflow bugs#14299

Merged
swoboda1337 merged 2 commits intoesphome:devfrom
swoboda1337:fix-sprinkler-millis-wrap
Feb 26, 2026
Merged

[sprinkler] Fix millis overflow and underflow bugs#14299
swoboda1337 merged 2 commits intoesphome:devfrom
swoboda1337:fix-sprinkler-millis-wrap

Conversation

@swoboda1337
Copy link
Member

@swoboda1337 swoboda1337 commented Feb 25, 2026

What does this implement/fix?

Fix millis overflow handling in SprinklerValveOperator using wrapping 32-bit subtraction instead of absolute deadline comparisons. Also fix several pre-existing bugs found during review.

Approach

Rather than switching to 64-bit millis_64() (which has performance overhead in the hot loop() path — called ~7100 times/min), use the standard embedded pattern of wrapping subtraction: (now - start) > delay instead of now > (start + delay). This correctly handles the 49.7-day millis() rollover with zero additional cost, using the already-cached App.get_loop_component_start_time().

The start_millis_ and stop_millis_ fields are changed from uint64_t to optional<uint32_t> to eliminate the use of 0 as a sentinel value (which is a valid millis() timestamp that occurs every 49.7 days).

Bugs fixed:

loop() — valve stops mid-run after millis wrap:
The code compared uint32_t now against uint64_t start_millis_ deadline. After wrap, now (promoted to 64-bit) could never reach the 64-bit deadline, so it fell into the else branch which called stop() with a TODO comment: "perhaps millis() rolled over...or something else is horribly wrong!". With wrapping subtraction, the comparisons work correctly and the bailout is removed.

time_remaining() — returns ~49.7 days remaining after wrap:
The deadline start_millis_ + delay + duration exceeds UINT32_MAX but millis() can never reach it as a 32-bit value, so the valve appears stuck running forever. Now uses elapsed-time subtraction which handles wrap correctly.

time_remaining() — unsigned underflow when stopped during start delay:
Pre-existing bug: if a valve was stopped during the start_delay_ phase (before the run began), run_duration_ - (stop - start) would underflow since the elapsed time could exceed run_duration_. Now correctly detects this case and returns the full run duration.

time_remaining() — millis zero sentinel unreliable:
Pre-existing bug: start_millis_ == 0 was used to mean "not started", but 0 is a valid millis() value. Changed to optional<uint32_t> with .has_value() checks, matching the pattern used in [pn532] Replace millis zero sentinel with optional (#14295).

total_cycle_time_enabled_incomplete_valves() — unsigned underflow:
incomplete_valve_count-- wraps to UINT32_MAX when both incomplete and enabled counts are zero, producing a bogus cycle time.

set_repeat() — unchecked optional dereference:
Pre-existing bug: repeat.value() called without has_value() check. Changed to value_or(0) to match the usage two lines below in the same function.

queue_valve() — unchecked optional dereference:
Pre-existing bug: run_duration.value() called without has_value() check. Changed to value_or(0) to match the log statement in the same function.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Developer breaking change (an API change that could break external components)
  • Code quality improvements to existing code or addition of tests
  • Other

Related issue or feature (if applicable):

N/A

Pull request in esphome-docs with documentation (if applicable):

N/A

Test Environment

  • ESP32
  • ESP32 IDF
  • ESP8266
  • RP2040
  • BK72xx
  • RTL87xx
  • LN882x
  • nRF52840

Example entry for config.yaml:

# No config changes

Checklist:

  • The code change is tested and works locally.
  • Tests have been added to verify that the new code works (under tests/ folder).

Copilot AI review requested due to automatic review settings February 25, 2026 22:26
@swoboda1337 swoboda1337 requested a review from kbx81 as a code owner February 25, 2026 22:26
@github-actions
Copy link
Contributor

👋 Hi there! I've automatically requested reviews from codeowners based on the files changed in this PR.

@kbx81 - You've been requested to review this PR as codeowner(s) of 1 file(s) that were modified. Thanks for your time! 🙏

@github-actions
Copy link
Contributor

To use the changes from this PR as an external component, add the following to your ESPHome configuration YAML file:

external_components:
  - source: github://pr#14299
    components: [sprinkler]
    refresh: 1h

(Added by the PR bot)

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to make SprinklerValveOperator::time_remaining() safe across millis() wrap-around by switching from an absolute “deadline” comparison to elapsed-time subtraction.

Changes:

  • Reworks time_remaining() to compute elapsed since start_millis_ and compare against start_delay_ + run_duration_.
  • Returns remaining seconds based on total_duration - elapsed rather than completed_millis - millis().

@swoboda1337 swoboda1337 marked this pull request as draft February 25, 2026 22:30
@swoboda1337 swoboda1337 force-pushed the fix-sprinkler-millis-wrap branch from df1c220 to 7558e63 Compare February 25, 2026 22:36
@github-actions
Copy link
Contributor

github-actions bot commented Feb 25, 2026

Memory Impact Analysis

Components: sprinkler
Platform: esp8266-ard

Metric Target Branch This PR Change
RAM 28,812 bytes 28,812 bytes ➡️ +0 bytes (0.00%)
Flash 297,039 bytes 296,895 bytes 📉 ✅ -144 bytes (-0.05%)
📊 Component Memory Breakdown
Component Target Flash PR Flash Change
[esphome]sprinkler 13,402 bytes 13,267 bytes 📉 ✅ -135 bytes (-1.01%)
🔍 Symbol-Level Changes (click to expand)

Changed Symbols

Symbol Target Size PR Size Change
esphome::sprinkler::SprinklerValveOperator::time_remaining() 195 bytes 119 bytes 📉 -76 bytes (-38.97%)
esphome::sprinkler::SprinklerValveOperator::loop() 120 bytes 72 bytes 📉 -48 bytes (-40.00%)
esphome::sprinkler::Sprinkler::fsm_transition_from_valve_run_() 352 bytes 348 bytes 📉 -4 bytes (-1.14%)
esphome::sprinkler::Sprinkler::Sprinkler(char const*) 352 bytes 348 bytes 📉 -4 bytes (-1.14%)
esphome::sprinkler::Sprinkler::set_valve_run_duration(esphome::optional, esphome::o...esphome::sprinkler::Sprinkler::set_valve_run_duration(esphome::optional, esphome::optional)
201 bytes 198 bytes 📉 -3 bytes (-1.49%)
esphome::sprinkler::Sprinkler::loop() 103 bytes 101 bytes 📉 -2 bytes (-1.94%)
esphome::sprinkler::SprinklerValveOperator::SprinklerValveOperator() 30 bytes 28 bytes 📉 -2 bytes (-6.67%)
esphome::sprinkler::Sprinkler::multiplier() 15 bytes 17 bytes 📈 +2 bytes (+13.33%)
esphome::sprinkler::Sprinkler::set_repeat(esphome::optional<unsigned int>) 97 bytes 96 bytes 📉 -1 bytes (-1.03%)
esphome::sprinkler::SprinklerValveOperator::stop() 122 bytes 123 bytes 📈 +1 bytes (+0.82%)
esphome::sprinkler::Sprinkler::previous_valve_number_(esphome::optional<unsigned int>, bool, bool) 181 bytes 182 bytes 📈 +1 bytes (+0.55%)
esphome::Trigger<>::trigger() 18 bytes 17 bytes 📉 -1 bytes (-5.56%)
esphome::sprinkler::Sprinkler::pump_in_use(esphome::switch_::Switch*) 197 bytes 196 bytes 📉 -1 bytes (-0.51%)
esphome::sprinkler::SprinklerValveOperator::set_valve(esphome::sprinkler::SprinklerValve*) 55 bytes 56 bytes 📈 +1 bytes (+1.82%)
esphome::sprinkler::Sprinkler::active_valve() 47 bytes 48 bytes 📈 +1 bytes (+2.13%)

Note: This analysis measures static RAM and Flash usage only (compile-time allocation).
Dynamic memory (heap) cannot be measured automatically.
⚠️ You must test this PR on a real device to measure free heap and ensure no runtime memory issues.

This analysis runs automatically when components change. Memory usage is measured from a representative test configuration.

@codecov-commenter
Copy link

codecov-commenter commented Feb 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.28%. Comparing base (2e16783) to head (cc06909).
⚠️ Report is 30 commits behind head on dev.

Additional details and impacted files
@@           Coverage Diff           @@
##              dev   #14299   +/-   ##
=======================================
  Coverage   74.28%   74.28%           
=======================================
  Files          55       55           
  Lines       11595    11595           
  Branches     1583     1583           
=======================================
  Hits         8613     8613           
  Misses       2578     2578           
  Partials      404      404           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@swoboda1337 swoboda1337 force-pushed the fix-sprinkler-millis-wrap branch from 7558e63 to 03f69c2 Compare February 25, 2026 23:01
@esphome esphome bot removed the small-pr PR < 30 lines label Feb 25, 2026
@swoboda1337 swoboda1337 changed the title [sprinkler] Fix millis overflow in time_remaining [sprinkler] Fix millis overflow and underflow bugs Feb 25, 2026
@swoboda1337 swoboda1337 marked this pull request as ready for review February 25, 2026 23:06
@swoboda1337 swoboda1337 marked this pull request as draft February 26, 2026 03:30
@swoboda1337 swoboda1337 force-pushed the fix-sprinkler-millis-wrap branch 2 times, most recently from 4f56715 to d0a21f3 Compare February 26, 2026 03:45
Switch SprinklerValveOperator from 32-bit millis() to 64-bit
App.scheduler.millis_64() which matches the existing uint64_t
start_millis_ and stop_millis_ fields. This fixes several issues:

- loop(): remove broken millis rollover bailout that would stop the
  valve mid-run after ~49.7 days. The 32-bit now could never reach
  the 64-bit deadline, so the else branch would always trigger after
  wrap. With millis_64() the comparisons work correctly.

- start()/stop(): store 64-bit timestamps to match field types.

- time_remaining(): use 64-bit arithmetic instead of truncating
  cast hack. Previously the uint64_t start_millis_ was cast to
  uint32_t which loses high bits after wrap.

Also fix unsigned underflow in total_cycle_time_enabled_incomplete_valves()
where incomplete_valve_count-- would wrap to UINT32_MAX when both
incomplete and enabled counts are zero.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Made-with: Cursor
@swoboda1337 swoboda1337 force-pushed the fix-sprinkler-millis-wrap branch from a7cfc84 to b430f55 Compare February 26, 2026 04:16
@swoboda1337 swoboda1337 added this to the 2026.2.3 milestone Feb 26, 2026
@swoboda1337 swoboda1337 marked this pull request as ready for review February 26, 2026 04:18
@swoboda1337 swoboda1337 requested a review from bdraco February 26, 2026 04:26
@swoboda1337
Copy link
Member Author

Thanks

@swoboda1337 swoboda1337 merged commit 6c253f0 into esphome:dev Feb 26, 2026
28 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Feb 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants