Fix Hydrojet V02 MEDIUM selection: step MAX then MEDIUM from OFF - #153
OdynBrouwer wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new control-path behaviors require corresponding unit test updates/additions, and the new code should avoid blanket except Exception in the MEDIUM-from-OFF stepping logic.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes selecting MEDIUM bubbles on Hydrojet V02 devices by handling a firmware quirk (OFF → MAX → MEDIUM) across V02 backends, and improving UI behavior when gateways only report bubbles as binary on/off.
Changes:
- Update AWS IoT + SmartSpa
set_bubblesto step MAX then MEDIUM when MEDIUM is requested from OFF. - Update SmartSpa write encoding to preserve explicit
wave_statelevels (0/40/100) instead of collapsing to 1/0. - Add optimistic UI state for the 3-way bubbles select to avoid “bounce back” when read-back is binary.
File summaries
| File | Description |
|---|---|
| custom_components/bestway/translation.py | Fix invalid exception syntax so translation can import and parse safely. |
| custom_components/bestway/smartspa/api.py | Preserve 0/40/100 wave_state writes and implement MAX→MEDIUM stepping for MEDIUM-from-OFF. |
| custom_components/bestway/select.py | Add short-lived optimistic selection state for 3-way bubbles select on binary-reporting gateways. |
| custom_components/bestway/features.py | Clarify Hydrojet V02 bubbles-level capabilities in the device feature comment. |
| custom_components/bestway/aws_iot/api.py | Implement MAX→MEDIUM stepping for MEDIUM-from-OFF on V02 devices. |
Review details
Suppressed comments (2)
custom_components/bestway/aws_iot/api.py:774
- The broad
except Exceptionhere can hide real programming errors and makes cancellation behavior harder to reason about.RawSnapshot.attrsis always a dict, so this branch can safely read the cache without a blanket exception handler; likewiseasyncio.sleep()shouldn't be wrapped in a catch-all.
if bubbles == BubblesLevel.MEDIUM:
try:
cached = self._raw_state.get(device_id)
current_wave = cached.attrs.get("wave") if cached else None
except Exception:
current_wave = None
if current_wave in (None, 0, "0"):
await self.set_device_state(device_id, {"wave_state": 100})
try:
await asyncio.sleep(1)
except Exception:
pass
custom_components/bestway/smartspa/api.py:582
- The broad
except Exceptionblocks here are unnecessary and can mask unexpected errors. Since_raw_statestoresRawSnapshotwithattrs: dict[str, Any], this can be written without a blanket exception handler, andasyncio.sleep()should remain cancellable.
if bubbles == BubblesLevel.MEDIUM:
try:
cached = self._raw_state.get(device_id)
current_wave = cached.attrs.get("wave") if cached else None
except Exception:
current_wave = None
if current_wave in (None, 0, "0"):
await self.set_device_state(device_id, {"wave_state": 100})
try:
await asyncio.sleep(1)
except Exception:
pass
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # MEDIUM from OFF: the panel ignores a direct 40, so step MAX first. | ||
| if bubbles == BubblesLevel.MEDIUM: | ||
| try: | ||
| cached = self._raw_state.get(device_id) | ||
| current_wave = cached.attrs.get("wave") if cached else None | ||
| except Exception: | ||
| current_wave = None | ||
| if current_wave in (None, 0, "0"): | ||
| await self.set_device_state(device_id, {"wave_state": 100}) |
| def _handle_coordinator_update(self) -> None: | ||
| """Clear optimistic state once real data confirms it, or after a | ||
| short timeout. | ||
|
|
||
| Some gateways (e.g. SmartSpa for F12D9Q) only report bubbles as | ||
| binary on/off even for panels that expose real MEDIUM/MAX levels, so | ||
| the reported state alone can never confirm a MEDIUM choice. Keep the | ||
| optimistic value until fresh data arrives that matches it, or until | ||
| the timeout forces a fall back to whatever the gateway last reported. | ||
| """ | ||
| if self._optimistic is not None and self.status is not None: | ||
| actual = self.status.bubbles | ||
| matched = _BUBBLES_OPTIONS.get(actual) == self._optimistic | ||
| timed_out = monotonic() - self._optimistic_set_at >= _OPTIMISTIC_TIMEOUT_S | ||
| if matched or timed_out: | ||
| self._optimistic = None | ||
| super()._handle_coordinator_update() |
| # Hydrojet panels (e.g. F12D9Q San Francisco HydroJet Pro) expose | ||
| # real OFF/MEDIUM/MAX levels (0/40/100) on the touch panel even | ||
| # though the Bestway app only offers on/off, and the gateway | ||
| # otherwise writes plain 1/0. Preserve an explicit 0/40/100 level | ||
| # so a MEDIUM (40) or MAX (100) command is not collapsed onto the | ||
| # generic on (1); plain bools still map to 1/0 for on/off-only | ||
| # hardware. | ||
| if key == "wave_state" and numeric in (0, 40, 100): | ||
| return numeric | ||
| return 1 if numeric else 0 |
|
Hi @cdpuk - just to confirm this fix has been verified live, not just unit-tested. I've been running this exact change on my own hardware for a while now:
Thanks for taking a look! |
Hydrojet panels (e.g. F12D9Q San Francisco HydroJet Pro) expose real OFF/MEDIUM/MAX levels on the touch panel even though the Bestway app only offers on/off, and they ignore a direct MEDIUM (40) command while they are OFF - the physical cycle is OFF -> MAX -> MEDIUM -> OFF. - aws_iot/smartspa set_bubbles: when MEDIUM is requested while the last known level is OFF, write MAX (100) first, wait for it to land, then write MEDIUM (40). MAX and OFF stay single direct writes. - smartspa _to_write_value: keep explicit 0/40/100 wave_state levels instead of collapsing them onto the generic on (1). Plain bools still map to 1/0, so on/off-only hardware is unaffected. - const: HYDROJET_STEP_SETTLE_S holds the pause the MAX -> MEDIUM step needs, since both V02 backends perform it. - features: the F12D9Q comment now records that the panel has real 3 levels even though the app only offers on/off. - tests: cover the level-preserving writes and the MAX -> MEDIUM step on both V02 backends. The select half of this fix (keeping the chosen level visible on a gateway that only reports bubbles back as binary on/off) is already in main via the OptimisticValue helper merged in cdpuk#161.
Summary
Fixes MEDIUM bubbles selection on Hydrojet V02 devices (e.g. F12D9Q San
Francisco HydroJet Pro). The Bestway app only exposes on/off bubbles, but
these panels have real OFF/MEDIUM/MAX levels on the touch panel - and they
ignore a direct MEDIUM (40) command while OFF (the physical cycle is
OFF -> MAX -> MEDIUM -> OFF).
Rebased onto current
main(v1.11.1). Two parts of the earlier revision aregone, because they are no longer needed:
main- theOptimisticValuehelper merged in Consistency and readability pass across the three-backend refactor #161;translation.pyexcept TypeError, ValueErrorclause needs no change:bare-tuple
exceptis valid syntax on Python 3.14+ (PEP 758), which is thisproject minimum.
Root cause
Two compounding issues on the V02 backends:
OFF -> MAX -> MEDIUM, so a MEDIUM command sent while the device is OFF
lands on nothing (or MAX), making the MEDIUM option in the 3-way select
appear unresponsive.
wave_statewrite to 1/0(
_to_write_value), so even the step-to-MAX-then-MEDIUM sequence could notbe expressed - 40 and 100 both became a generic
1.Fix
aws_iot/api.py/smartspa/api.pyset_bubbles: when MEDIUM is requestedwhile the device is OFF, send MAX (100) first, let it settle (~1 s), then
send MEDIUM (40). MAX and OFF are still sent directly.
smartspa/api.py_to_write_value: preserve an explicit 0/40/100wave_statelevel instead of collapsing it onto the generic on (1). Plainbools still map to 1/0.
const.py:HYDROJET_STEP_SETTLE_Sholds the pause the MAX -> MEDIUM stepneeds, since both V02 backends perform it.
features.py: correct the F12D9Q comment - the panel has real 3 levels eventhough the app only offers on/off.
V02 backends.
Verification
Verified live on an F12D9Q San Francisco HydroJet Pro (EU) over the SmartSpa
backend: selecting MEDIUM from OFF now reliably lands the panel on MEDIUM, and
MAX/OFF still behave as before. The 3-way select tile also stays on the chosen
level instead of snapping back right after you pick it.
One caveat worth flagging: on the SmartSpa backend, on/off-only hardware now
receives
100instead of1for "on". That gateway has always treated anynon-zero as on, and MAX/OFF behave as before on the F12D9Q, but only that
panel has been verified live.