Skip to content

Commit 8aa021a

Browse files
authored
fix: high CPU usage by replacing async context manager with setup/cleanup pattern to avoid Cython bug (#301)
1 parent 9ef79e0 commit 8aa021a

File tree

6 files changed

+998
-1062
lines changed

6 files changed

+998
-1062
lines changed

src/habluetooth/channels/bluez.py

Lines changed: 13 additions & 300 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,10 @@
22

33
import asyncio
44
import logging
5-
import socket
6-
from asyncio import timeout as asyncio_timeout
7-
from collections.abc import AsyncIterator, Callable
8-
from contextlib import asynccontextmanager
5+
from collections.abc import Callable
96
from struct import Struct
107
from typing import TYPE_CHECKING, cast
118

12-
from btsocket import btmgmt_socket
13-
from btsocket.btmgmt_socket import BluetoothSocketError
14-
15-
from ..const import (
16-
FAST_CONN_LATENCY,
17-
FAST_CONN_TIMEOUT,
18-
FAST_MAX_CONN_INTERVAL,
19-
FAST_MIN_CONN_INTERVAL,
20-
MEDIUM_CONN_LATENCY,
21-
MEDIUM_CONN_TIMEOUT,
22-
MEDIUM_MAX_CONN_INTERVAL,
23-
MEDIUM_MIN_CONN_INTERVAL,
24-
ConnectParams,
25-
)
269
from ..scanner import HaScanner
2710

2811
_LOGGER = logging.getLogger(__name__)
@@ -49,13 +32,6 @@
4932
CONN_PARAM_STRUCT = Struct("<H6sBHHHH")
5033
CONN_PARAM_PACK = CONN_PARAM_STRUCT.pack
5134

52-
CONNECTION_ERRORS = (
53-
BluetoothSocketError,
54-
OSError,
55-
asyncio.TimeoutError,
56-
PermissionError,
57-
)
58-
5935

6036
def _set_future_if_not_done(future: asyncio.Future[None] | None) -> None:
6137
"""Set the future result if not done."""
@@ -89,27 +65,27 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
8965
_set_future_if_not_done(self.connection_made_future)
9066
self.transport = cast(asyncio.Transport, transport)
9167

92-
@asynccontextmanager
93-
async def command_response(
94-
self, opcode: int
95-
) -> AsyncIterator[asyncio.Future[tuple[int, bytes]]]:
68+
def setup_command_response(self, opcode: int) -> asyncio.Future[tuple[int, bytes]]:
9669
"""
97-
Context manager for handling command responses.
70+
Set up a future for handling command responses.
9871
9972
Usage:
100-
async with protocol.command_response(opcode) as future:
101-
transport.write(command)
73+
future = protocol.setup_command_response(opcode)
74+
transport.write(command)
75+
try:
10276
status, data = await future
77+
finally:
78+
protocol.cleanup_command_response(opcode)
10379
"""
10480
future: asyncio.Future[tuple[int, bytes]] = (
10581
asyncio.get_running_loop().create_future()
10682
)
10783
self._pending_commands[opcode] = future
108-
try:
109-
yield future
110-
finally:
111-
# Clean up if the future wasn't resolved
112-
self._pending_commands.pop(opcode, None)
84+
return future
85+
86+
def cleanup_command_response(self, opcode: int) -> None:
87+
"""Clean up command response future."""
88+
self._pending_commands.pop(opcode, None)
11389

11490
def _add_to_buffer(self, data: bytes | bytearray | memoryview) -> None:
11591
"""Add data to the buffer."""
@@ -250,266 +226,3 @@ def connection_lost(self, exc: Exception | None) -> None:
250226
_LOGGER.info("Bluetooth management socket connection closed")
251227
self.transport = None
252228
self._on_connection_lost()
253-
254-
255-
class MGMTBluetoothCtl:
256-
"""Class to control interfaces using the BlueZ management API."""
257-
258-
def __init__(self, timeout: float, scanners: dict[int, HaScanner]) -> None:
259-
"""Initialize the control class."""
260-
# Internal state
261-
self.timeout = timeout
262-
self.protocol: BluetoothMGMTProtocol | None = None
263-
self.sock: socket.socket | None = None
264-
self.scanners = scanners
265-
self._reconnect_task: asyncio.Task[None] | None = None
266-
self._on_connection_lost_future: asyncio.Future[None] | None = None
267-
self._shutting_down = False
268-
269-
def close(self) -> None:
270-
"""Close the management interface."""
271-
self._shutting_down = True
272-
if self._reconnect_task:
273-
self._reconnect_task.cancel()
274-
if self.protocol and self.protocol.transport:
275-
self.protocol.transport.close()
276-
self.protocol = None
277-
btmgmt_socket.close(self.sock)
278-
279-
def _on_connection_lost(self) -> None:
280-
"""Handle connection lost."""
281-
if self._shutting_down:
282-
_LOGGER.debug("Bluetooth management socket connection lost during shutdown")
283-
else:
284-
_LOGGER.debug("Bluetooth management socket connection lost, reconnecting")
285-
_set_future_if_not_done(self._on_connection_lost_future)
286-
self._on_connection_lost_future = None
287-
288-
async def reconnect_task(self) -> None:
289-
"""Monitor the connection and reconnect if needed."""
290-
while not self._shutting_down:
291-
if self._on_connection_lost_future:
292-
await self._on_connection_lost_future
293-
if self._shutting_down:
294-
break # type: ignore[unreachable]
295-
_LOGGER.debug("Reconnecting to Bluetooth management socket")
296-
try:
297-
await self._establish_connection()
298-
except CONNECTION_ERRORS:
299-
_LOGGER.debug("Bluetooth management socket connection timed out")
300-
# If we get a timeout, we should try to reconnect
301-
# after a short delay
302-
await asyncio.sleep(1)
303-
304-
async def _establish_connection(self) -> None:
305-
"""Establish a connection to the Bluetooth management socket."""
306-
_LOGGER.debug("Establishing Bluetooth management socket connection")
307-
self.sock = btmgmt_socket.open()
308-
loop = asyncio.get_running_loop()
309-
connection_made_future: asyncio.Future[None] = loop.create_future()
310-
try:
311-
async with asyncio_timeout(self.timeout):
312-
# _create_connection_transport accessed
313-
# directly to avoid SOCK_STREAM check
314-
# see https://bugs.python.org/issue38285
315-
_, protocol = await loop._create_connection_transport( # type: ignore[attr-defined]
316-
self.sock,
317-
lambda: BluetoothMGMTProtocol(
318-
connection_made_future,
319-
self.scanners,
320-
self._on_connection_lost,
321-
lambda: self._shutting_down,
322-
),
323-
None,
324-
None,
325-
)
326-
await connection_made_future
327-
except TimeoutError:
328-
btmgmt_socket.close(self.sock)
329-
raise
330-
_LOGGER.debug("Bluetooth management socket connection established")
331-
self.protocol = cast(BluetoothMGMTProtocol, protocol)
332-
self._on_connection_lost_future = loop.create_future()
333-
334-
def _has_mgmt_capabilities_from_status(self, status: int) -> bool:
335-
"""
336-
Check if a MGMT command status indicates we have capabilities.
337-
338-
Returns True if we have capabilities, False otherwise.
339-
340-
Status codes:
341-
- 0x00 = Success (we have permissions)
342-
- 0x01 = Unknown Command (might happen if kernel is too old)
343-
- 0x0D = Invalid Parameters
344-
- 0x10 = Not Powered (for some operations)
345-
- 0x11 = Invalid Index (adapter doesn't exist but we have permissions)
346-
- 0x14 = Permission Denied (missing NET_ADMIN/NET_RAW)
347-
"""
348-
if status == 0x14: # Permission denied
349-
_LOGGER.debug(
350-
"MGMT capability check failed with permission denied - "
351-
"missing NET_ADMIN/NET_RAW"
352-
)
353-
return False
354-
if status in (0x00, 0x11): # Success or Invalid Index
355-
_LOGGER.debug("MGMT capability check passed (status: %#x)", status)
356-
return True
357-
# Unknown status - log it and assume no permissions to be safe
358-
_LOGGER.debug(
359-
"MGMT capability check returned unexpected status %#x - "
360-
"assuming missing permissions",
361-
status,
362-
)
363-
return False
364-
365-
async def _check_capabilities(self) -> bool:
366-
"""
367-
Check if we have the necessary capabilities to use MGMT.
368-
369-
Returns True if we have capabilities, False otherwise.
370-
"""
371-
if not self.protocol or not self.protocol.transport:
372-
return False
373-
374-
# Try GET_CONNECTIONS for adapter 0 - this is a read-only command
375-
# that requires NET_ADMIN privileges but doesn't change any state
376-
header = COMMAND_HEADER_PACK(
377-
MGMT_OP_GET_CONNECTIONS, # opcode
378-
0, # controller index 0 (hci0)
379-
0, # no parameters
380-
)
381-
382-
try:
383-
return await self._do_mgmt_op_get_connections(header)
384-
except (TimeoutError, OSError) as ex:
385-
_LOGGER.debug(
386-
"MGMT capability check failed: %s - "
387-
"likely missing NET_ADMIN/NET_RAW",
388-
ex,
389-
)
390-
return False
391-
392-
async def _do_mgmt_op_get_connections(self, header: bytes) -> bool:
393-
"""Send a MGMT_OP_GET_CONNECTIONS command and check capabilities."""
394-
if TYPE_CHECKING:
395-
assert self.protocol is not None
396-
assert self.protocol.transport is not None
397-
398-
async with self.protocol.command_response(
399-
MGMT_OP_GET_CONNECTIONS
400-
) as response_future:
401-
self.protocol.transport.write(header)
402-
# Wait for response with timeout
403-
async with asyncio_timeout(5.0):
404-
status, _ = await response_future
405-
return self._has_mgmt_capabilities_from_status(status)
406-
407-
async def setup(self) -> None:
408-
"""Set up management interface."""
409-
await self._establish_connection()
410-
411-
# Check if we actually have the capabilities to use MGMT
412-
if not await self._check_capabilities():
413-
# Mark as shutting down to prevent reconnection attempts
414-
self._shutting_down = True
415-
# Close the connection and raise an error to trigger fallback
416-
if self.protocol and self.protocol.transport:
417-
self.protocol.transport.close()
418-
btmgmt_socket.close(self.sock)
419-
raise PermissionError(
420-
"Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management"
421-
)
422-
423-
self._reconnect_task = asyncio.create_task(self.reconnect_task())
424-
425-
def load_conn_params(
426-
self,
427-
adapter_idx: int,
428-
address: str,
429-
address_type: int,
430-
params: ConnectParams,
431-
) -> bool:
432-
"""
433-
Load connection parameters for a specific device.
434-
435-
Args:
436-
adapter_idx: Adapter index (e.g., 0 for hci0)
437-
address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
438-
address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2)
439-
params: Connection parameters to load (ConnectParams.FAST or
440-
ConnectParams.MEDIUM)
441-
442-
Returns:
443-
True if command was sent successfully
444-
445-
"""
446-
if not self.protocol or not self.protocol.transport:
447-
_LOGGER.error("Cannot load conn params: no connection")
448-
return False
449-
450-
# Parse MAC address
451-
addr_bytes = bytes.fromhex(address.replace(":", ""))
452-
if len(addr_bytes) != 6:
453-
_LOGGER.error("Invalid MAC address: %s", address)
454-
return False
455-
456-
# Build command structure
457-
# struct mgmt_cp_load_conn_param {
458-
# uint16_t param_count;
459-
# struct mgmt_conn_param params[0];
460-
# }
461-
# struct mgmt_conn_param {
462-
# struct mgmt_addr_info addr;
463-
# uint16_t min_interval;
464-
# uint16_t max_interval;
465-
# uint16_t latency;
466-
# uint16_t timeout;
467-
# }
468-
# struct mgmt_addr_info {
469-
# bdaddr_t bdaddr;
470-
# uint8_t type;
471-
# }
472-
473-
# Get the appropriate connection parameters based on the enum
474-
if params is ConnectParams.FAST:
475-
min_interval = FAST_MIN_CONN_INTERVAL
476-
max_interval = FAST_MAX_CONN_INTERVAL
477-
latency = FAST_CONN_LATENCY
478-
timeout = FAST_CONN_TIMEOUT
479-
else: # params is ConnectParams.MEDIUM:
480-
min_interval = MEDIUM_MIN_CONN_INTERVAL
481-
max_interval = MEDIUM_MAX_CONN_INTERVAL
482-
latency = MEDIUM_CONN_LATENCY
483-
timeout = MEDIUM_CONN_TIMEOUT
484-
485-
# Pack the command
486-
cmd_data = CONN_PARAM_PACK(
487-
1, # param_count = 1
488-
addr_bytes[::-1], # bdaddr (reversed for little endian)
489-
address_type, # address type
490-
min_interval, # min_interval
491-
max_interval, # max_interval
492-
latency, # latency
493-
timeout, # timeout
494-
)
495-
496-
# Send the command
497-
try:
498-
header = COMMAND_HEADER_PACK(
499-
MGMT_OP_LOAD_CONN_PARAM, # opcode
500-
adapter_idx, # controller index
501-
len(cmd_data), # parameter length
502-
)
503-
self.protocol.transport.write(header + cmd_data)
504-
_LOGGER.debug(
505-
"Loaded conn params for %s: interval=%d-%d, latency=%d, timeout=%d",
506-
address,
507-
min_interval,
508-
max_interval,
509-
latency,
510-
timeout,
511-
)
512-
return True
513-
except Exception:
514-
_LOGGER.exception("Failed to load conn params")
515-
return False

0 commit comments

Comments
 (0)