A Python library to communicate with Ooler Sleep System Bluetooth devices via BLE GATT characteristics. Built on bleak and bleak-retry-connector.
Designed for use with the Home Assistant Ooler integration, but can be used standalone.
pip install ooler-ble-clientimport asyncio
from bleak import BleakScanner
from ooler_ble_client import OolerBLEDevice
async def main():
# Discover the device
device = await BleakScanner.find_device_by_name("OOLER")
# Create client and connect
client = OolerBLEDevice(model="OOLER")
client.set_ble_device(device)
await client.connect()
# Read state
print(client.state)
# Control the device
await client.set_power(True)
await client.set_temperature(72)
await client.set_mode("Regular")
# Listen for state changes
def on_state_change(state):
print(f"State changed: {state}")
unsubscribe = client.register_callback(on_state_change)
# Clean up
unsubscribe()
await client.stop()
asyncio.run(main())Main client class.
set_ble_device(device)-- set the BLE device to connect toconnect()-- establish BLE connection, read initial state, subscribe to notificationsstop()-- unsubscribe from notifications and disconnectis_connected-- whether the device is currently connectedstate-- currentOolerBLEStateregister_callback(fn)-- register a state change callback, returns an unsubscribe functionasync_poll()-- read all characteristics from the deviceset_power(bool)-- turn device on/off (re-sends mode and temperature on power-on)set_mode(OolerMode)-- set pump mode:"Silent","Regular", or"Boost"set_temperature(int)-- set target temperature in the current display unitset_clean(bool)-- start/stop clean cycle (automatically powers on)set_temperature_unit(TemperatureUnit)-- set device display unit:"C"or"F"address-- BLE device addressregister_connection_event_callback(fn)-- register a connectivity event callback, returns an unsubscribe function
read_sleep_schedule()-- read the schedule from the device (updates cache)set_sleep_schedule(nights)-- write a structured schedule (list ofSleepScheduleNight)set_sleep_schedule_events(events)-- write a flat event list directlyclear_sleep_schedule()-- clear the schedule on the devicesync_clock(now=None)-- sync the device's internal clock (used for schedule execution). Pass a timezone-aware datetime, or omit to use the system timezone.sleep_schedule-- cachedOolerSleepSchedule(orNoneif not yet read)sleep_schedule_events-- cached schedule as a flatlist[SleepScheduleEvent]
Dataclass with fields: power, mode, set_temperature, actual_temperature, water_level, clean, temperature_unit.
OolerSleepSchedule-- weekly schedule containing a list ofSleepScheduleNightand a sequence counterSleepScheduleNight-- one night's program: day (0=Mon), temperature steps, off time, optional warm wakeSleepScheduleEvent-- a single event in the flat wire format (minute of week + temperature). Minute of week is minutes elapsed since Monday 00:00 (e.g. Tuesday 6:00am = 1800).WarmWake-- warm wake configuration: target temperature and duration in minutesbuild_sleep_schedule(bedtime, wake_time, temp_f, ...)-- convenience builder for uniform schedules (same program across selected days, with optional warm wake and extra temperature steps)
ConnectionEvent-- a connectivity event withtype,timestamp, and optionaldetailConnectionEventType-- enum:CONNECTED,DISCONNECTED,SUBSCRIPTION_MISMATCH,SUBSCRIPTION_RECOVERED,FORCED_RECONNECT
OolerMode--Literal["Silent", "Regular", "Boost"]TemperatureUnit--Literal["C", "F"]OolerConnectionError-- raised when all retry attempts are exhausted (inherits fromBleakError)
All connection attempts are serialized through an internal asyncio.Lock. If connect() is called while another connection is already in progress, the second caller waits for the first to complete and then returns immediately if the connection succeeded. This prevents duplicate connections and race conditions.
GATT write operations use a two-level retry strategy:
- Immediate retry -- if a write fails with a transient BLE error (e.g., ESP32 proxy hiccup), the operation is retried immediately without reconnecting.
- Reconnect + retry -- if the immediate retry also fails, the library forces a full disconnect/reconnect cycle (with a 0.5s backoff) and retries the operation once more.
If all three attempts fail, an OolerConnectionError is raised.
async_poll() uses a similar pattern: if the poll fails, it reconnects and retries once.
The library catches BleakError, EOFError, BrokenPipeError, and asyncio.TimeoutError during GATT operations. These cover the common failure modes seen with ESP32 BLE proxies.
When the BLE connection drops unexpectedly, the internal client reference is cleared immediately so is_connected returns False. Registered callbacks are fired to notify consumers of the state change. The library does not automatically reconnect -- the consumer (e.g., a Home Assistant integration) is responsible for triggering reconnection on the next advertisement or poll cycle.
ESP32 BLE proxies (ESPHome) have a global limit of 12 notification registrations across all connected devices. This library subscribes to 4 notification characteristics per device:
- Power, Mode, Set Temperature, Actual Temperature
Water level and clean status are polled (via async_poll()) rather than subscribed to notifications. This means two Ooler devices use 8 of 12 available slots, leaving headroom for other BLE devices.
ESP32 proxies support 3 simultaneous BLE connections by default. Each Ooler device holds one connection slot for as long as it's connected.
The Ooler has a quirk in how it handles temperature units:
- Set temperature (
SETTEMP_CHAR) is always stored and reported in Fahrenheit by the device, regardless of the display unit setting. - Actual temperature (
ACTUALTEMP_CHAR) is reported in whatever unit the device display is set to.
The library handles this automatically:
state.set_temperatureis converted to the current display unit on read.set_temperature(value)accepts a value in the current display unit and converts to Fahrenheit before writing to the device.state.actual_temperatureis passed through as-is from the device.
The display unit is read once on connect and cached. It can be changed via set_temperature_unit().
Apache-2.0