Skip to content

Fix Hydrojet V02 MEDIUM selection: step MAX then MEDIUM from OFF - #153

Open
OdynBrouwer wants to merge 1 commit into
cdpuk:mainfrom
OdynBrouwer:main
Open

OdynBrouwer wants to merge 1 commit into
cdpuk:mainfrom
OdynBrouwer:main

Conversation

@OdynBrouwer

@OdynBrouwer OdynBrouwer commented Sep 2, 2026 •

Copy link
Copy Markdown

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 are
gone, because they are no longer needed:

Root cause

Two compounding issues on the V02 backends:

  1. Direct MEDIUM (40) from OFF is ignored by the panel. The firmware steps
    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.
  2. The SmartSpa gateway collapses every wave_state write to 1/0
    (_to_write_value), so even the step-to-MAX-then-MEDIUM sequence could not
    be expressed - 40 and 100 both became a generic 1.

Fix

  • aws_iot/api.py / smartspa/api.py set_bubbles: when MEDIUM is requested
    while 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/100
    wave_state level instead of collapsing it onto the generic on (1). Plain
    bools still map to 1/0.
  • const.py: HYDROJET_STEP_SETTLE_S holds the pause the MAX -> MEDIUM step
    needs, since both V02 backends perform it.
  • features.py: correct the F12D9Q comment - 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.

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 100 instead of 1 for "on". That gateway has always treated any
non-zero as on, and MAX/OFF behave as before on the F12D9Q, but only that
panel has been verified live.

Copilot AI lite review requested due to automatic review settings September 2, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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_bubbles to step MAX then MEDIUM when MEDIUM is requested from OFF.
  • Update SmartSpa write encoding to preserve explicit wave_state levels (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 Exception here can hide real programming errors and makes cancellation behavior harder to reason about. RawSnapshot.attrs is always a dict, so this branch can safely read the cache without a blanket exception handler; likewise asyncio.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 Exception blocks here are unnecessary and can mask unexpected errors. Since _raw_state stores RawSnapshot with attrs: dict[str, Any], this can be written without a blanket exception handler, and asyncio.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.

Comment on lines +762 to +770
# 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})
Comment on lines +85 to +101
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()
Comment on lines +472 to 481
# 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
@OdynBrouwer

Copy link
Copy Markdown
Author

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:

  • Device: Lay-Z-Spa San Francisco HydroJet Pro (F12D9Q), EU, SmartSpa backend.
  • The panel really does have physical OFF / MEDIUM / MAX levels on the touch panel, even though the Bestway app only exposes on/off.
  • Selecting MEDIUM from OFF now reliably lands on MEDIUM: the step to MAX first, then MEDIUM, works every time (previously a direct MEDIUM command was ignored and the option appeared dead).
  • MAX and OFF still behave exactly as before.
  • The 3-way select tile now also stays put on the chosen level (MEDIUM included) instead of snapping back to MAX/OFF right after you pick it - the optimistic state in select.py covers the SmartSpa gateway only reporting bubbles back as binary on/off.

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.
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