Skip to content

fix: send state-changing commands once, stop bypassing the rate limiter - #51

Merged
Xerolux merged 2 commits into
mainfrom
claude/fehlersuche-optimierung-doku-wszv4m
Sep 8, 2026
Merged

Xerolux merged 2 commits into
mainfrom
claude/fehlersuche-optimierung-doku-wszv4m

Conversation

@Xerolux

@Xerolux Xerolux commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

A full read-only audit of 0.0.38, and the fixes for what it found. The audit's work order is included as docs/IMPROVEMENT_PLAN.md.

The two reasons this release exists. Both could act on the pool without anyone asking.

  • Retryability was decided by HTTP method, but the controller applies almost every state change through GET. A setFunctionManually request that timed out after the controller applied it was repeated up to max_retries times. For the PUSH actions, the digital-rule trigger and the cover commands, that undid the change or moved the cover a second time; initUpdate and setOutputTestmode are not idempotent either. Command endpoints are now listed in NON_RETRYABLE_ENDPOINTS and sent exactly once whatever their method, and each command method also passes retryable=False explicitly. Reads keep their retries.
  • When the wait for a rate-limit token expired, the request was logged, delayed by a second and then sent without a token. Under sustained load every caller that waited out the timeout hit the controller at once, which is the pile-up the limiter exists to prevent. It now raises.

Also fixed

  • Typed readings accessors raised TypeError on the list values the firmware documents (DOS_*_STATE is "LIST, STRING"), crashing the consumer instead of returning None.
  • An HTML login or captive-portal page opened the circuit breaker, hiding the payload error that explains the problem behind "circuit breaker is open".
  • set_config() stripped invalid characters from keys, so DOSAGE_ph.minus silently became the real, different setting DOSAGE_phminus; it also coerced None and lists into "None" and "1 2", and non-finite floats into 0.0. All of that raises now. validate_device_key() additionally stopped upper-casing, because controller keys are case-sensitive (pH_value).
  • sanitize_numeric("1,5") returned 15.0. A single comma is read as a decimal separator before the digit-extraction fallback runs; "12.5 mL" still yields its number.
  • Credentials in a host string reached the exception message, which lands in Home Assistant config-flow errors and logs. 4xx bodies are truncated to 200 characters.
  • Disabling certificate verification performed a blocking CA-store read inside the event loop; aiohttp is handed ssl=False instead.
  • Smaller ones: get_log() returned [] for has_more and left \r on CRLF lines; manual_dosing() raised a bare TypeError for a non-numeric duration; validate_duration(True) counted as one second; parse_uptime_string() returned zero if seconds appeared; parse_epoch_seconds() turned negative values into 1969 dates; _command_result() returned a dict body without the success key.

Behaviour and API surface

  • The aiohttp<3.15 cap is removed. Home Assistant follows aiohttp closely and the cap would have made pip refuse this package inside Home Assistant the day core moved past it.
  • Hostnames may contain underscores; mDNS and router-assigned names use them and were rejected outright.
  • Request priorities now match what const_api.py documents: state changes CRITICAL, set_config() HIGH, history and logs LOW. A pump-off command no longer queues behind sensor polling.
  • Symbols earlier changelogs promised as public are actually exported now (validate_duration, RS485_PUMP_NAMES, DEVICE_STATE_MAPPING, SWITCH_FUNCTIONS, API_PRIORITY_*, CircuitBreakerState, and more). SWITCH_FUNCTIONS labels are English; consumers that displayed them see changed strings.
  • _trigger_dosing() gained a source argument for the firmware's from field. H2O2 manual dosing stays unsupported until the mapping is confirmed against firmware.

Verification: ruff clean, mypy clean, pytest -q tests 250 to 260 (eight new regression tests: switch commands, rule triggers and initUpdate sent once while reads still retry; a limiter timeout sends nothing; an HTML page does not open the breaker; the hostname error hides credentials; underscores accepted). The smoke test against the mock server passes. Three tests that codified the old wrong behaviour were rewritten to assert the new one, and each says why in its docstring.

🤖 Generated with Claude Code

https://claude.ai/code/session_018jZ4x6m2TDjVGmvX5Up5Ge

Read-only audit of 0.0.38: state-changing GET commands are retried
(double actuation), the rate-limiter timeout bypasses the limiter,
readings parsers crash on list values, the aiohttp upper bound will
block installation inside Home Assistant, plus smaller defects, test
gaps and documentation drift. Each item names the fix and the test to
write first. Shared package IDs with violet-hass/docs/IMPROVEMENT_PLAN.md.

No code is changed by this commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jZ4x6m2TDjVGmvX5Up5Ge
A full read-only audit of 0.0.38. The two safety fixes are the reason this
lands as one change: both could act on the pool without anyone asking.

Retryability was decided by HTTP method, but the controller applies almost
every state change through GET. A setFunctionManually request that timed out
after the controller applied it was repeated up to max_retries times; for the
PUSH actions - the digital-rule trigger and the cover commands - that undid
the change or moved the cover twice. Command endpoints are now listed in
NON_RETRYABLE_ENDPOINTS and sent exactly once whatever their method.

When the wait for a rate-limit token expired, the request was sent anyway
without a token, so every caller that waited out the timeout hit the
controller at once - the pile-up the limiter exists to prevent. It now fails.

Also fixed: typed readings accessors raised TypeError on the list values the
firmware documents; an HTML login page opened the circuit breaker and hid the
payload error; set_config rewrote invalid keys into different real settings
and coerced None and lists into 'None' and '1 2'; sanitize_numeric turned
'1,5' into 15.0; credentials in a host string reached the exception message;
disabling certificate verification performed a blocking CA-store read inside
the event loop.

The aiohttp <3.15 cap is removed: Home Assistant follows aiohttp closely and
the cap would have made pip refuse this package inside HA.

Eight regression tests added, 250 -> 260.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jZ4x6m2TDjVGmvX5Up5Ge
@Xerolux Xerolux changed the title docs: audit-based improvement plan for the API package fix: send state-changing commands once, stop bypassing the rate limiter Sep 8, 2026
@Xerolux
Xerolux marked this pull request as ready for review September 8, 2026 18:57
@Xerolux
Xerolux merged commit 375cd17 into main Sep 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants