Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 84 additions & 31 deletions custom_components/blanco_unit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,24 +309,41 @@ def extract_errors(self, response: dict[str, Any]) -> list[dict[str, Any]]:

async def read_response_chunks(self, client: BleakClient) -> list[bytes]:
"""Read response chunks from the characteristic."""
chunks = []
chunks: list[bytes] = []
expected = 1
last_data = b""
attempts = 0
max_attempts = 40
max_attempts = 60
consecutive_errors = 0

while len(chunks) < expected and attempts < max_attempts:
try:
data = await client.read_gatt_char(CHARACTERISTIC_UUID)
consecutive_errors = 0
if data != last_data:
last_data = data
chunks.append(data)
if data[0] == 0xFF:
expected = data[2]
attempts += 1
await asyncio.sleep(0.05)
except Exception as e: # noqa: BLE001
_LOGGER.error("Read error: %s", e)
break
err_str = str(e)
if "Not connected" in err_str or "NotConnected" in err_str:
_LOGGER.warning("Read failed (disconnected): %s", e)
break
# Transient ATT errors (e.g. 0x0e) mean the device
# hasn't prepared its response yet — keep polling.
consecutive_errors += 1
if consecutive_errors >= 10:
_LOGGER.warning(
"Read failed %d times, giving up: %s",
consecutive_errors, e,
)
break
_LOGGER.debug("Transient read error (attempt %d): %s", attempts, e)
await asyncio.sleep(0.1)
attempts += 1

if len(chunks) != expected:
raise TimeoutError(
Expand Down Expand Up @@ -363,6 +380,9 @@ async def send_pairing_request(
for packet in packets:
await client.write_gatt_char(CHARACTERISTIC_UUID, packet, response=True)

# Delay to let device process before polling reads
await asyncio.sleep(0.3)

# Read response
chunks = await self.read_response_chunks(client)
return self.parse_response(chunks)
Expand Down Expand Up @@ -407,6 +427,9 @@ async def send_request(
for packet in packets:
await client.write_gatt_char(CHARACTERISTIC_UUID, packet, response=True)

# Delay to let device process before polling reads
await asyncio.sleep(0.3)

# Read response
chunks = await self.read_response_chunks(client)
return self.parse_response(chunks)
Expand Down Expand Up @@ -470,40 +493,70 @@ async def disconnect(self) -> None:
await self._session_data.client.disconnect()

async def _connect(self) -> _BlancoUnitSessionData:
"""Connect to the device if not already connected and authenticate."""
"""Connect to the device if not already connected and authenticate.

The device tends to drop BLE connections quickly after GATT
discovery, so we retry the full connect + pairing sequence
up to 3 times before giving up.
"""
async with self._connect_lock:
_LOGGER.debug("Connecting to device %s", self._device.address)
if self._session_data:
_LOGGER.debug("Already connected")
return self._session_data

client = await establish_connection(
client_class=BleakClientWithServiceCache,
device=self._device,
name=self._device.name or "Unknown Device",
disconnected_callback=self._handle_disconnect,
timeout=120,
)

# Create protocol instance for this session
protocol = _BlancoUnitProtocol(mtu=MTU_SIZE)

# Perform initial pairing
result = await self._perform_pairing(client, protocol)

_LOGGER.debug(
"Connected and paired with device ID: %s, device type: %d",
result.dev_id,
result.dev_type,
)
self._session_data = _BlancoUnitSessionData(
client=client,
dev_id=result.dev_id,
dev_type=result.dev_type,
protocol=protocol,
last_err: Exception | None = None
for attempt in range(3):
client: BleakClient | None = None
try:
client = await establish_connection(
client_class=BleakClientWithServiceCache,
device=self._device,
name=self._device.name or "Unknown Device",
disconnected_callback=self._handle_disconnect,
timeout=120,
)

# Create protocol instance for this session
protocol = _BlancoUnitProtocol(mtu=MTU_SIZE)

# Perform initial pairing
result = await self._perform_pairing(client, protocol)

_LOGGER.debug(
"Connected and paired with device ID: %s, device type: %d",
result.dev_id,
result.dev_type,
)
self._session_data = _BlancoUnitSessionData(
client=client,
dev_id=result.dev_id,
dev_type=result.dev_type,
protocol=protocol,
)
self._connection_callback(
self._session_data.client.is_connected
)
return self._session_data
except BlancoUnitAuthenticationError:
# Wrong PIN — no point retrying
raise
except Exception as err: # noqa: BLE001
last_err = err
_LOGGER.warning(
"Connection attempt %d/3 failed: %s",
attempt + 1, err,
)
if client is not None:
try:
await client.disconnect()
except Exception: # noqa: BLE001
pass
await asyncio.sleep(0.5)

raise BlancoUnitConnectionError(
f"Failed after 3 connection attempts: {last_err}"
)
self._connection_callback(self._session_data.client.is_connected)
return self._session_data

def _handle_disconnect(self, _: BleakClient) -> None:
"""Reset session and call connection callback."""
Expand Down