fix: dosing setpoint validation gaps, retry-bypassed rate limiter, decimal parsing - #47
Merged
Merged
Conversation
…cimal parsing
Full-codebase review across api.py, circuit_breaker.py, utils_rate_limiter.py,
readings.py, _api_outputs.py, _api_dosing.py, _api_system.py, and
utils_sanitizer.py. Fixes the issues confirmed to be real bugs, each backed
by a regression test:
- set_dosing_parameters() bypassed the pH/ORP/chlorine setpoint range
checks enforced by the sibling set_ph_target()/set_orp_target()/
set_min_chlorine_level() methods; it now validates any known setpoint
key before writing.
- The rate limiter's wait_if_needed() was only awaited once before the
retry loop, so retried requests fired without re-acquiring a token;
moved inside the loop so every attempt is throttled.
- dosing_daily_amounts_ml and get_system_services() used int() on
decimal-formatted strings ("12.5", "1.0"), which the controller
actually sends; both now parse via float() first.
- InputSanitizer.sanitize_numeric() stripped 'e'/'+'/'-' from scientific
notation instead of parsing it, silently turning "1e10" into 110.0;
it now tries a direct float() parse first.
- InputSanitizer.validate_ph_value() allowed pH 9.0 while the actual
controller setpoint range (SETPOINT_RANGES) tops out at 8.0; aligned
the two.
- set_output_test_mode() sent its payload without URL-encoding, unlike
the identical pattern in set_switch_state(); now consistent.
- set_can_amount() accepted fractional amount_ml in (0, 1) that then
silently truncated to 0 via int(); the bound check now applies after
truncation.
- set_rs485_live() raised a bare TypeError instead of ValueError for
non-numeric slave_id/level input.
- 5xx/429 responses triggered raise_for_status() without draining the
body first, forcing aiohttp to close the connection instead of
reusing it on retry.
Verified with pytest (221 passed), mypy, and ruff.
Reviewer's GuideStrengthens safety and correctness across dosing, system, and HTTP layers by enforcing setpoint validation paths, fixing decimal/scientific numeric parsing, ensuring the rate limiter and connection reuse behave correctly on retries, and adding regression tests for each fix. File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Xerolux
marked this pull request as ready for review
August 14, 2026 07:42
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- In
set_target_valueyou computenumeric_value = _to_float(key, value)but still pass the originalvaluethrough toset_config; consider sendingnumeric_valueinstead so the payload is consistently normalized and matches the value you just validated. - The new
get_system_servicesparsing usesbool(int(float(raw_value))), which will silently treat any non-zero numeric (e.g. '0.5') as enabled; if the protocol only allows 0/1 or 0.0/1.0, you may want to assert on those specific values and raise for anything else rather than coercing arbitrary numerics.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `set_target_value` you compute `numeric_value = _to_float(key, value)` but still pass the original `value` through to `set_config`; consider sending `numeric_value` instead so the payload is consistently normalized and matches the value you just validated.
- The new `get_system_services` parsing uses `bool(int(float(raw_value)))`, which will silently treat any non-zero numeric (e.g. '0.5') as enabled; if the protocol only allows 0/1 or 0.0/1.0, you may want to assert on those specific values and raise for anything else rather than coercing arbitrary numerics.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Full-codebase review (correctness + optimization) of
violet_poolcontroller_api, coveringapi.py,circuit_breaker.py,utils_rate_limiter.py,readings.py,_api_readings.py,_api_outputs.py,_api_dosing.py,_api_system.py, andutils_sanitizer.py.pytest,mypy,ruff, andbanditwere all clean before this change; the findings below came from a manual line-by-line review plus verification against the mock server fixtures. Each fix below is backed by a new regression test.set_dosing_parameters()bypassed setpoint safety validation — it forwarded the caller's dict straight toset_config(), skipping the pH/ORP/min-chlorine range checks thatset_ph_target()/set_orp_target()/set_min_chlorine_level()enforce for the exact same keys. A caller could push a pH setpoint of20.0straight to the dosing controller. Now validates any known setpoint key (SETPOINT_RANGES) present in the payload before sending.wait_if_needed()was awaited once before the retry loop in_execute_request(), so each of the (up tomax_retries) real HTTP attempts inside the loop skipped the limiter entirely. Moved the wait inside the loop so every attempt re-acquires a token.dosing_daily_amounts_ml/get_system_services()dropped decimal values — both usedint()directly on strings the controller actually sends in decimal form ("12.5","1.0"), which raises/crashes instead of parsing. Confirmed against the project's own mock fixtures (tests/mock_server.py). Both now go throughfloat()first;get_system_services()now raisesVioletPayloadError(matching the existing pattern inis_dosage_enabled()) instead of an unhandledValueErrorfor genuinely invalid states.InputSanitizer.sanitize_numeric()mangled scientific notation — it strippede/+/-characters instead of parsing them, so"1e10"silently became110.0(off by ~9 orders of magnitude) with no error logged. Now attempts a directfloat()parse first.InputSanitizer.validate_ph_value()used the wrong range — allowed pH up to 9.0 while the controller's actual setpoint bound (SETPOINT_RANGES[TARGET_PH], enforced byset_ph_target()) is 8.0. A value like 8.5 would pass this validator and then be rejected by the setter. Aligned to 6.0–8.0.set_output_test_mode()sent an un-encoded payload — inconsistent with the identicalset_switch_state()pattern, which URL-encodes viaquote(payload, safe=","). Fixed for consistency/robustness against reserved characters.set_can_amount()truncation vs. validation mismatch —amount_mlin(0, 1)passed the> 0check but was then truncated to0byint()before being sent, silently violating its own documented contract.set_rs485_live()raised the wrong exception type — a non-numericslave_id/levelraised a bareTypeErrorfromfloat()/int()instead of the documentedValueError.raise_for_status()— forced aiohttp to close the connection instead of returning it to the pool, adding a fresh TCP/TLS handshake to every retry during exactly the period the controller is already degraded.Not changed (reviewed, judged out of scope / too speculative for this pass)
COVER_STATEencoding (COVER_STATE_MAPexists but isn't wired into the typed parser) — no confirmed evidence current firmware emits it._dosing_standalonecross-call mutable state under concurrentasyncio.gather— narrow/latent, would need a wider refactor.get_hardware_profile()re-fetching the fullgetReadingspayload — efficiency-only, no correctness impact.Retry-Afterheader only supporting numeric seconds (not HTTP-date form) — graceful degradation already in place.Test plan
pytest— 221 passed (was 211; added targeted regression tests for each fix)mypy violet_poolcontroller_api/— cleanruff check— cleanbandit -r violet_poolcontroller_api/— no issuesGenerated by Claude Code
Summary by Sourcery
Tighten dosing and system API validation and rate limiting behavior to match controller contracts and avoid silent failures.
Bug Fixes:
Tests: