Release 1.0.0#3
Merged
Merged
Conversation
…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
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
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)
SleepScheduleNightAPI and flatSleepScheduleEventwire formatregister_connection_event_callback; poll/state consistency detector that recovers missed notifications via tiered re-subscribe → forced reconnectLibrary improvements
OolerModeandTemperatureUnitwith runtime validationOolerConnectionErrorfor consistent exception handlingpy.typedmarker with mypy --strict complianceQuality
Packaging & docs
diagnostics/Test plan
pytest— 380 passed, 100% coveragemypy --strict— 0 errors, 12 files