"""Async BLE client for Casper Glow lights.""" from __future__ import annotations import asyncio import enum import logging from collections.abc import Callable from dataclasses import dataclass from typing import Any from bleak import BleakClient from bleak.backends.device import BLEDevice from bleak_retry_connector import establish_connection from .const import ( HANDSHAKE_TIMEOUT, QUERY_STATE_BODY, READ_CHAR_UUID, RECONNECT_PACKET, STATE_RESPONSE_TIMEOUT, WRITE_CHAR_UUID, ) from .exceptions import CommandError, HandshakeTimeoutError from .protocol import ( build_action_packet, build_brightness_body, extract_token_from_notify, parse_protobuf_fields, parse_state_response, payload_contains_ready_marker, ) _LOGGER = logging.getLogger(__name__) _BATTERY_PERCENTAGE: dict[int, int] = {3: 25, 4: 50, 5: 75, 6: 100} class BatteryLevel(enum.IntEnum): """Discrete battery level reported by the device. The Casper Glow encodes battery state as a 4-step enum in field 7 of the state response (a nested protobuf sub-message; inner field 2). The raw values map to approximate percentages: PCT_25 (3) — ~25 % — low; device should be charged. PCT_50 (4) — ~50 %. PCT_75 (5) — ~75 %. PCT_100 (6) — ~100 % — fully charged. Values not listed here are mapped to ``None`` by :meth:`from_raw`. """ PCT_25 = 3 PCT_50 = 4 PCT_75 = 5 PCT_100 = 6 @property def percentage(self) -> int: """Return the approximate battery percentage as an integer.""" return _BATTERY_PERCENTAGE[self.value] def __str__(self) -> str: return f"{self.percentage}%" @classmethod def from_raw(cls, value: int) -> BatteryLevel | None: """Return the matching member, or None if the value is unrecognised.""" try: return cls(value) except ValueError: _LOGGER.debug("Unrecognised battery level raw value: %d", value) return None @dataclass class GlowState: """Represents the current state of a Casper Glow light.""" is_on: bool | None = None brightness_level: int | None = None battery_level: BatteryLevel | None = None dimming_time_remaining_ms: int | None = None # remaining time in milliseconds configured_dimming_time_minutes: int | None = ( None # set by set_brightness_and_dimming_time() ) is_paused: bool | None = None is_charging: bool | None = None raw_state: bytes | None = None @property def dimming_time_minutes(self) -> int | None: """Return remaining dimming time in minutes, derived from milliseconds.""" if self.dimming_time_remaining_ms is None: return None return self.dimming_time_remaining_ms // 60_000 class CasperGlow: """Async client for a Casper Glow light.""" def __init__( self, ble_device: BLEDevice, client: BleakClient | None = None, ) -> None: self._ble_device = ble_device self._external_client = client self._state = GlowState() self._callbacks: list[Callable[[GlowState], None]] = [] self._ble_lock = asyncio.Lock() @property def name(self) -> str | None: """Return the device name.""" return self._ble_device.name @property def address(self) -> str: """Return the device address.""" return self._ble_device.address @property def state(self) -> GlowState: """Return the current device state.""" return self._state @property def is_on(self) -> bool | None: """Return whether the light is on, or None if unknown.""" return self._state.is_on def set_ble_device(self, ble_device: BLEDevice) -> None: """Update the BLE device reference.""" self._ble_device = ble_device def register_callback( self, callback: Callable[[GlowState], None] ) -> Callable[[], None]: """Register a callback for state updates. Returns an unregister function.""" self._callbacks.append(callback) def _unregister() -> None: self._callbacks.remove(callback) return _unregister def _fire_callbacks(self) -> None: """Notify all registered callbacks of a state change.""" for callback in self._callbacks: callback(self._state) def _parse_state_notification(self, data: bytes) -> bool: """Parse a BLE notification for state information. Returns True if the notification contained state data and the state was updated, False otherwise. The device reports state in protobuf field 19 (nested inside field 4 of the notification). The field-19 inner sub-fields: * sub-field 1: power/mode indicator (1 = on, 3 = off) * sub-field 2: remaining dimming time in milliseconds (counts down to 0 when off) * sub-field 3: configured total dimming duration in milliseconds (0 when off) * sub-field 4: paused indicator (0 = not paused, 1 = paused) * sub-field 5: always 0 in all real-device captures — not the charging indicator * sub-field 7: nested message whose inner field 1 is a charging indicator (0 = not charging, 3 = charging observed); inner field 2 is a discrete battery level enum (3=25%, 4=50%, 5=75%, 6=100%). * sub-field 8: always 100 in all captures — NOT the battery level. """ state_fields = parse_state_response(data) if state_fields is None: return False self._state.raw_state = data # Sub-field 1: power/mode indicator (1 = on, 3 = off) sf1 = state_fields.get(1) if sf1 is not None and isinstance(sf1[0], int): self._state.is_on = sf1[0] == 1 if not self._state.is_on: self._state.is_paused = False # Sub-field 2: remaining dimming time in milliseconds (counts down to 0) sf2 = state_fields.get(2) if sf2 is not None and isinstance(sf2[0], int): self._state.dimming_time_remaining_ms = sf2[0] # Sub-field 3: configured total duration in milliseconds. # Only update when non-zero — device reports 0 when off. sf3 = state_fields.get(3) if sf3 is not None and isinstance(sf3[0], int): total_ms = sf3[0] if total_ms > 0: self._state.configured_dimming_time_minutes = total_ms // 60_000 # Force remaining time to 0 when device is off regardless of what the # device reported in sub-field 3. if self._state.is_on is False: self._state.dimming_time_remaining_ms = 0 # Sub-field 4: paused indicator (0 = not paused, 1 = paused) sf4 = state_fields.get(4) if sf4 is not None and isinstance(sf4[0], int): self._state.is_paused = sf4[0] != 0 # Sub-field 7: nested message. # Inner field 1: charging indicator (0 = not charging, ≥1 = charging; # 3 observed in practice). # Inner field 2: battery level enum (3=25%, 4=50%, 5=75%, 6=100%). # Sub-field 8 is always 100 and is NOT the battery level. sf7 = state_fields.get(7) if sf7 is not None and isinstance(sf7[0], bytes): inner = parse_protobuf_fields(sf7[0]) inner1 = inner.get(1) if inner1 is not None and isinstance(inner1[0], int): self._state.is_charging = inner1[0] != 0 inner2 = inner.get(2) if inner2 is not None and isinstance(inner2[0], int): self._state.battery_level = BatteryLevel.from_raw(inner2[0]) _LOGGER.debug("Parsed state from notification: %s", self._state) return True async def handshake(self) -> None: """Test connectivity by performing the handshake without sending a command. Raises HandshakeTimeoutError or ConnectionError on failure. """ ready_event = asyncio.Event() def _on_notify(_sender: Any, data: bytearray) -> None: if payload_contains_ready_marker(bytes(data)): ready_event.set() client = await establish_connection( BleakClient, self._ble_device, self._ble_device.address ) try: await client.start_notify(READ_CHAR_UUID, _on_notify) await client.write_gatt_char(WRITE_CHAR_UUID, RECONNECT_PACKET) try: await asyncio.wait_for(ready_event.wait(), HANDSHAKE_TIMEOUT) except TimeoutError as err: raise HandshakeTimeoutError( f"Device did not become ready within {HANDSHAKE_TIMEOUT}s" ) from err finally: await client.disconnect() async def turn_on(self) -> None: """Turn the light on.""" from .const import ACTION_BODY_ON await self._execute_command(ACTION_BODY_ON) self._state.is_on = True self._fire_callbacks() async def turn_off(self) -> None: """Turn the light off.""" from .const import ACTION_BODY_OFF await self._execute_command(ACTION_BODY_OFF) self._state.is_on = False self._state.dimming_time_remaining_ms = 0 self._fire_callbacks() async def pause(self) -> None: """Pause an active dimming sequence.""" from .const import ACTION_BODY_PAUSE await self._execute_command(ACTION_BODY_PAUSE) self._state.is_paused = True self._fire_callbacks() async def resume(self) -> None: """Resume a paused dimming sequence.""" from .const import ACTION_BODY_RESUME await self._execute_command(ACTION_BODY_RESUME) self._state.is_paused = False self._fire_callbacks() async def set_brightness_and_dimming_time( self, level: int, dimming_time_minutes: int ) -> None: """Set brightness percentage and dimming time together. Both values are required and transmitted in a single BLE command (the device protocol encodes them together in a field-18 protobuf message). Valid brightness values: 60, 70, 80, 90, 100 (iOS app levels 1–5). Valid dimming time values: 15, 30, 45, 60, 90 (minutes). """ from .const import BRIGHTNESS_LEVELS, DIMMING_TIME_MINUTES if level not in BRIGHTNESS_LEVELS: raise ValueError( f"Invalid brightness {level}; must be one of {BRIGHTNESS_LEVELS}" ) if dimming_time_minutes not in DIMMING_TIME_MINUTES: raise ValueError( f"Invalid dimming time {dimming_time_minutes};" f" must be one of {DIMMING_TIME_MINUTES}" ) body = build_brightness_body(level, dimming_time_minutes * 60_000) await self._execute_command(body) self._state.brightness_level = level self._state.configured_dimming_time_minutes = dimming_time_minutes self._fire_callbacks() async def _execute_command(self, action_body: bytes) -> None: """Connect, handshake, send command, disconnect.""" async with self._ble_lock: ready_event = asyncio.Event() token: int | None = None def _on_notify(_sender: Any, data: bytearray) -> None: nonlocal token _LOGGER.debug("Notification: %s", data.hex()) self._parse_state_notification(bytes(data)) if payload_contains_ready_marker(bytes(data)): extracted = extract_token_from_notify(bytes(data)) if extracted is not None: token = extracted ready_event.set() client = self._external_client or await establish_connection( BleakClient, self._ble_device, self._ble_device.address ) try: if not client.is_connected: await client.connect() await client.start_notify(READ_CHAR_UUID, _on_notify) await client.write_gatt_char(WRITE_CHAR_UUID, RECONNECT_PACKET) try: await asyncio.wait_for(ready_event.wait(), HANDSHAKE_TIMEOUT) except TimeoutError as err: raise HandshakeTimeoutError( f"Device did not become ready within {HANDSHAKE_TIMEOUT}s" ) from err if token is None: raise CommandError("Ready marker seen but no token extracted") packet = build_action_packet(token, action_body) await client.write_gatt_char(WRITE_CHAR_UUID, packet) _LOGGER.debug("Sent action packet: %s", packet.hex()) finally: if self._external_client is None: await client.disconnect() async def query_state(self) -> GlowState: """Query the device for its current state. Connects, performs the handshake, sends a state query command, waits for the state response notification, and returns the updated GlowState. """ async with self._ble_lock: ready_event = asyncio.Event() state_event = asyncio.Event() token: int | None = None def _on_notify(_sender: Any, data: bytearray) -> None: nonlocal token _LOGGER.debug("Notification: %s", data.hex()) if self._parse_state_notification(bytes(data)): state_event.set() if payload_contains_ready_marker(bytes(data)): extracted = extract_token_from_notify(bytes(data)) if extracted is not None: token = extracted ready_event.set() client = self._external_client or await establish_connection( BleakClient, self._ble_device, self._ble_device.address ) try: if not client.is_connected: await client.connect() await client.start_notify(READ_CHAR_UUID, _on_notify) await client.write_gatt_char(WRITE_CHAR_UUID, RECONNECT_PACKET) try: await asyncio.wait_for(ready_event.wait(), HANDSHAKE_TIMEOUT) except TimeoutError as err: raise HandshakeTimeoutError( f"Device did not become ready within {HANDSHAKE_TIMEOUT}s" ) from err if token is None: raise CommandError("Ready marker seen but no token extracted") packet = build_action_packet(token, QUERY_STATE_BODY) await client.write_gatt_char(WRITE_CHAR_UUID, packet) _LOGGER.debug("Sent state query: %s", packet.hex()) try: await asyncio.wait_for(state_event.wait(), STATE_RESPONSE_TIMEOUT) except TimeoutError: _LOGGER.warning("State response timeout — returning cached state") self._fire_callbacks() finally: if self._external_client is None: await client.disconnect() return self._state