|
2 | 2 |
|
3 | 3 | import asyncio |
4 | 4 | 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 |
9 | 6 | from struct import Struct |
10 | 7 | from typing import TYPE_CHECKING, cast |
11 | 8 |
|
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 | | -) |
26 | 9 | from ..scanner import HaScanner |
27 | 10 |
|
28 | 11 | _LOGGER = logging.getLogger(__name__) |
|
49 | 32 | CONN_PARAM_STRUCT = Struct("<H6sBHHHH") |
50 | 33 | CONN_PARAM_PACK = CONN_PARAM_STRUCT.pack |
51 | 34 |
|
52 | | -CONNECTION_ERRORS = ( |
53 | | - BluetoothSocketError, |
54 | | - OSError, |
55 | | - asyncio.TimeoutError, |
56 | | - PermissionError, |
57 | | -) |
58 | | - |
59 | 35 |
|
60 | 36 | def _set_future_if_not_done(future: asyncio.Future[None] | None) -> None: |
61 | 37 | """Set the future result if not done.""" |
@@ -89,27 +65,27 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: |
89 | 65 | _set_future_if_not_done(self.connection_made_future) |
90 | 66 | self.transport = cast(asyncio.Transport, transport) |
91 | 67 |
|
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]]: |
96 | 69 | """ |
97 | | - Context manager for handling command responses. |
| 70 | + Set up a future for handling command responses. |
98 | 71 |
|
99 | 72 | 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: |
102 | 76 | status, data = await future |
| 77 | + finally: |
| 78 | + protocol.cleanup_command_response(opcode) |
103 | 79 | """ |
104 | 80 | future: asyncio.Future[tuple[int, bytes]] = ( |
105 | 81 | asyncio.get_running_loop().create_future() |
106 | 82 | ) |
107 | 83 | 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) |
113 | 89 |
|
114 | 90 | def _add_to_buffer(self, data: bytes | bytearray | memoryview) -> None: |
115 | 91 | """Add data to the buffer.""" |
@@ -250,266 +226,3 @@ def connection_lost(self, exc: Exception | None) -> None: |
250 | 226 | _LOGGER.info("Bluetooth management socket connection closed") |
251 | 227 | self.transport = None |
252 | 228 | 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