Skip to content

Release 1.0.0#3

Merged
PostLogical merged 25 commits into
mainfrom
dev
Apr 19, 2026
Merged

Release 1.0.0#3
PostLogical merged 25 commits into
mainfrom
dev

Conversation

@PostLogical

Copy link
Copy Markdown
Owner

Summary

Promotes ooler-ble-client from beta to 1.0.0 production-stable. This PR covers all development since the initial beta releases on main.

Major features (since main)

  • Sleep schedule support (0.10.0) — read, write, and clear device sleep schedules with clock sync; structured SleepScheduleNight API and flat SleepScheduleEvent wire format
  • Connection health monitoring (0.11.0–0.11.1) — connection-event channel with register_connection_event_callback; poll/state consistency detector that recovers missed notifications via tiered re-subscribe → forced reconnect
  • Comprehensive BLE reverse engineering — full characteristic map, temperature unit quirks, schedule encoding/byte-swap workaround
  • Retry and reconnection hardening — two-level retry strategy, stale connection guard, broadened exception handling for ESP32 proxies

Library improvements

  • Literal types for OolerMode and TemperatureUnit with runtime validation
  • OolerConnectionError for consistent exception handling
  • py.typed marker with mypy --strict compliance
  • Temperature validation widened to 54–116°F for integration compatibility
  • Reduced notification slot usage (4 of 12 ESP32 slots per device)

Quality

  • 380 tests, 100% code coverage
  • mypy --strict clean across all source and test files
  • Zero TODOs/FIXMEs in source

Packaging & docs

  • README documents full API including sleep schedules, connection events, concurrency model, and ESP32 considerations
  • Diagnostic scripts organized in diagnostics/
  • Development status classifier updated to Production/Stable

Test plan

  • pytest — 380 passed, 100% coverage
  • mypy --strict — 0 errors, 12 files
  • Real-device testing across 0.10.0–0.11.1 release cycle

…A-specific dependencies

- Fix class-level mutable attributes (shared state bug) — move to __init__
- Fix infinite recursion in setters — raise RuntimeError if connect fails
- Fix is_connected side effect — make it a pure property
- Remove state.connected — integration uses client.is_connected directly
- Remove test_connection — integration uses connect()+async_poll() instead
- Remove advertisement.py — Ooler has no manufacturer data in advertisements
- Remove check_connection.py (commented out) and pair.py (dev script)
- Remove setup.py shim, DISCONNECT_DELAY dead code, wildcard imports
- Modernize pyproject.toml to PEP 621 [project] format
- Use keyword args for OolerBLEState construction
- Initialize _ble_device in __init__ to prevent AttributeError
- Read DISPLAY_TEMPERATURE_UNIT_CHAR: 0=F, 1=C (confirmed on hardware)
- Add set_temperature_unit() to toggle device display unit
- Convert set_temperature: SETTEMP_CHAR is always stored in F on device,
  library now converts to/from display unit automatically
- ACTUALTEMP_CHAR already reports in display unit, no conversion needed
- Remove pump_watts from state — value (0-5) is pump speed level, not
  watts, and not actionable for users
- Rename PUMP_WATTS_CHAR to PUMP_LEVEL_CHAR in const.py for accuracy
Allows integration's BLE callback to trigger fresh reconnection
on next advertisement instead of waiting for a stale client check.
If async_poll or any start_notify call fails after connection,
clear the client and disconnect so the next attempt starts fresh
instead of leaving the device in a half-connected state.
…read

- GATT operations now detect stale connections (BleakError on a
  supposedly-connected client) and automatically reconnect + retry once
- Reduced notification subscriptions from 6 to 4 per device (dropped
  WATER_LEVEL and CLEAN) to stay within ESP32 proxy limits (12 global
  notification slots). These values still update on periodic polls.
- Temperature unit (DISPLAY_TEMPERATURE_UNIT_CHAR) now read once on
  connect instead of every poll, reducing BLE traffic through proxies
- Removed unused imports (AdvertisementData, BleakGATTServiceCollection,
  BleakDBusError)
- Guard set_power(True) against None state fields on first use
- Wrap _notification_handler in try/except to prevent silent failures
- Add bounds check for mode values in notifications and polls
- Add input validation for set_mode, set_temperature, set_temperature_unit
- Add temperature range validation (55-115°F)
- Guard individual stop_notify calls in _execute_disconnect
- Increase establish_connection max_attempts to 5 for ESP32 proxy resilience
- Add 0.5s backoff in forced reconnect to let BLE stack clean up
- Remove unused TypeVar/WrapFuncType imports
- Switch from BleakClient to BleakClientWithServiceCache for GATT cache
  clearing on characteristic errors
- Catch BLEAK_RETRY_EXCEPTIONS (BleakError, EOFError, BrokenPipeError,
  asyncio.TimeoutError) instead of just BleakError
- Two-level retry: immediate retry first, then full reconnect — faster
  recovery for transient proxy hiccups
- Only fire notification callbacks when state actually changes
- Refactor set_power(True) to write mode/temp directly via GATT instead
  of calling set_mode/set_temperature, making power-on atomic
- Extract _write_gatt helper to reduce duplication
- Add PEP 561 py.typed marker for type checker support
- Fix ble_device_callback type safety (capture local after None check)
- Fix _write_gatt None safety (named function with explicit check)
- Add Coroutine type parameters to _retry_on_stale signature
- Default unknown mode to "Silent" instead of None
- Define OolerMode and TemperatureUnit Literal types in models.py
- Use Literal types in set_mode, set_temperature_unit, and OolerBLEState
- Export OolerMode and TemperatureUnit from package
- Type MODE_INT_TO_MODE_STATE as list[OolerMode] in const.py
- Change unknown mode fallback from "Silent" to "Regular" (normal default)
- Runtime validation kept alongside Literal types for safety
- OolerConnectionError inherits from BleakError so existing catches work
- Wrap final operation in _retry_on_stale and async_poll after exhausted
  retries, converting any BLEAK_RETRY_EXCEPTIONS to OolerConnectionError
- Export OolerConnectionError from package
- Add 132 tests covering conversions, models, notifications, callbacks,
  input validation, and instance isolation
- Expand README with badges, installation, usage example, and API docs
- Add PyPI classifiers and keywords for discoverability
- Add pytest-asyncio dev dependency
- Clean dist/ build artifacts
- 238 tests with 100% coverage including edge cases from live testing:
  concurrent connects, duplicate notifications, stop during connect,
  GATT timeout + disconnect, callback lifecycle, concurrent poll + notification
- Add concurrency/reconnection, ESP32 proxy, and temperature behavior docs
- Add CHANGELOG.md
- Add docstrings to all public setter methods
- Fix _disconnected_callback type annotation
…when-off

- Overhaul const.py: document all characteristics by service from BLE
  testing on firmware 15.20. Remove WARMWAKE_CHAR (not present on device).
  Add newly discovered characteristics (schedule, config, diagnostics).
- Fix temperature validation: accept 45 (LO), 55-115, 120 (HI). Reject
  dead zones 46-54 and 116-119 that the device clamps to sentinels.
- Handle writes-when-off: set_mode/set_temperature cache values when
  device is off (device silently drops writes). set_temperature_unit
  skips write with warning. set_clean still auto-powers-on.
- Add temperature boundary constants (TEMP_LO_F, TEMP_MIN_F, etc.)
Standalone scripts used for device testing and characteristic discovery:
- dump_characteristics.py: one-shot dump of all characteristics
- snapshot_characteristics.py: take/diff snapshots between app changes
- monitor_characteristics.py: continuous polling (single-connection limit)
- test_device_behavior.py: boundary testing for temp ranges and writes-when-off
The integration uses 54 and 116 as the slider edges to request LO/HI
from the device (which clamps 46-54→45 and 116-119→120). Accept the
full 54-116 range plus the LO (45) and HI (120) sentinels.
- New sleep_schedule.py module with two API layers:
  - Low-level: flat event list matching wire format (SleepScheduleEvent)
  - High-level: structured nights with per-night warm wake (OolerSleepSchedule)
  - Convenience builder for app-compatible uniform schedules
  - Round-trip encode/decode verified against real device snapshots

- Client methods: read_sleep_schedule, set_sleep_schedule,
  set_sleep_schedule_events, clear_sleep_schedule (lazy read)

- sync_clock writes BLE Current Time and Local Time Info with proper
  DST handling via zoneinfo; falls back to fixed-offset with DST unknown

- Device byte-swaps uint16 values on schedule GATT writes; client
  pre-swaps to compensate (other services unaffected)

- Updated const.py schedule annotations with fully decoded format

- 347 tests, 100% coverage

Verified on hardware: write round-trips match app-written data exactly,
device follows written schedules, clock sync persists correctly.
Addresses silent notification stalls (37-249 min) observed on ESPHome
BLE proxies where reads kept succeeding but the subscription state was
silently lost during a proxy-internal reconnect.

- Background watchdog forces a reconnect when the notification stream
  has been silent past 15 min while the device is powered (tunable via
  _NOTIFY_STALL_TIMEOUT_SECONDS in const.py, with 30s tick cadence and
  60s post-reconnect cooldown).
- New register_connection_event_callback() API delivers CONNECTED,
  DISCONNECTED, NOTIFY_STALL, and FORCED_RECONNECT events with
  per-callback exception isolation. NOTIFY_STALL carries
  stall_duration_seconds, FORCED_RECONNECT carries the trigger
  ("notify_stall", "poll_failure", "write_failure").
- is_connected returns True throughout a forced-reconnect window so
  downstream consumers do not race the library's reconnect with their
  own. On failure the flag clears and the unexpected-disconnect path
  takes over, so entities still go unavailable via the existing
  mechanism.
- establish_connection wrapped with a 3-attempt/20s outer backoff for
  BleakError("Bluetooth is already shutdown"), which spans the ~15s
  proxy blip instead of burning 5 inner attempts in ~2 seconds.
- decode_sleep_schedule_events signature widened to bytes | bytearray
  to match BleakClient.read_gatt_char (mypy --strict now clean).
- 26 new tests (373 total).
…ctor

The 0.11.0 notify-staleness watchdog force-reconnected after 15 minutes of
silence on the notification stream. The overnight soak on 2026-04-12/13
showed that during coast periods (pump off, at setpoint, ACTUALTEMP
genuinely stable) all four subscribed characteristics legitimately go
quiet for 15+ minutes, producing 30 spurious forced reconnects in 15.5 h
that cascaded at exact 15-minute intervals. No threshold on a negative
signal can resolve this — firmware changes to coast duration would
reintroduce the cascade.

The new detector runs inside async_poll: compare fresh GATT reads against
cached _state on the four notify-backed fields (power, mode,
set_temperature, actual_temperature). A disagreement is positive
affirmative proof of a missed notification, so the subscription is
suspect. Matching values prove either a healthy subscription or an
idle device — both operationally fine. The detector has zero false
positives during coast by construction, detects real subscription
failure within one poll cycle (≤5 min) of the next real transition,
has no tunable threshold, and runs with no background task.

Recovery is a two-tier ladder:
- Tier 1: stop_notify + start_notify on the existing BleakClient to
  re-arm CCCD descriptors without tearing down GATT. Cheap — one round
  trip per characteristic.
- Tier 2: full forced reconnect, escalated only if the next poll still
  shows a mismatch after Tier 1 was attempted.

The gap watchdog is deleted outright — every failure mode it could
catch is caught faster by the poll-consistency detector or the existing
poll_failure retry path. Insurance is not a sufficient justification
for keeping dead weight.

Removed:
- _NOTIFY_STALL_TIMEOUT_SECONDS, _WATCHDOG_TICK_SECONDS,
  _WATCHDOG_RECONNECT_COOLDOWN_SECONDS constants
- _notify_watchdog_loop, _watchdog_tick, _cancel_watchdog methods
- _last_notification_monotonic, _watchdog_task,
  _force_reconnect_cooldown_until instance state
- _watchdog_enabled_default class attribute and the
  _disable_notify_watchdog autouse test fixture
- ConnectionEventType.NOTIFY_STALL

Added:
- ConnectionEventType.SUBSCRIPTION_MISMATCH (detail.fields lists the
  sorted field names where poll/state disagreed)
- ConnectionEventType.SUBSCRIPTION_RECOVERED (Tier 1 re-subscribe
  succeeded)
- FORCED_RECONNECT gains trigger="subscription_mismatch" for Tier 2
  escalation
- 29 tests in tests/test_subscription_health.py covering match,
  Tier 1, Tier 2, stop_notify failure, coast (regression guard against
  the 0.11.0 cascade), first-poll-skip, poll_failure path preservation,
  API surface guards

Also: testpaths = ["tests"] in pyproject.toml so pytest stops
auto-collecting repo-root BLE diagnostic scripts (test_device_behavior.py
and friends).
- Bump version to 1.0.0, classifier to Production/Stable
- Document sleep schedule API, connection events, and address property in README
- Move remaining diagnostic scripts to diagnostics/
- Add tests for subscription recovery error paths (100% coverage)
- Fix all mypy --strict issues: proper return types, narrowed type ignores
- Replace Any with TypeVar on _retry_on_stale return type
@PostLogical
PostLogical merged commit cfce99f into main Apr 19, 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.

1 participant