pax_global_header00006660000000000000000000000064145712742560014527gustar00rootroot0000000000000052 comment=4b954d0a3e974a2e25f7857c0647b558487b77b4 home-assistant-libs-py-improv-ble-client-4b954d0/000077500000000000000000000000001457127425600217265ustar00rootroot00000000000000home-assistant-libs-py-improv-ble-client-4b954d0/.github/000077500000000000000000000000001457127425600232665ustar00rootroot00000000000000home-assistant-libs-py-improv-ble-client-4b954d0/.github/dependabot.yml000066400000000000000000000004101457127425600261110ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: pip directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 home-assistant-libs-py-improv-ble-client-4b954d0/.github/release-drafter.yml000066400000000000000000000002221457127425600270520ustar00rootroot00000000000000categories: - title: "⬆️ Dependencies" collapse-after: 1 labels: - "dependencies" template: | ## What's Changed $CHANGES home-assistant-libs-py-improv-ble-client-4b954d0/.github/workflows/000077500000000000000000000000001457127425600253235ustar00rootroot00000000000000home-assistant-libs-py-improv-ble-client-4b954d0/.github/workflows/pythonpublish.yml000066400000000000000000000015661457127425600307660ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: - published jobs: deploy: runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/py-improv-ble-client permissions: id-token: write steps: - uses: actions/checkout@v4.1.1 - name: Set up Python uses: actions/setup-python@v5.0.0 with: python-version: '3.11' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.8.12 home-assistant-libs-py-improv-ble-client-4b954d0/.github/workflows/release-drafter.yml000066400000000000000000000011251457127425600311120ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main permissions: contents: read jobs: update_release_draft: permissions: # write permission is required to create a github release contents: write # write permission is required for autolabeler # otherwise, read permission is required at least pull-requests: read runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v6.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} home-assistant-libs-py-improv-ble-client-4b954d0/.github/workflows/test.yml000066400000000000000000000021151457127425600270240ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Run Tests on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.1 - name: Set up Python 3.11 uses: actions/setup-python@v5.0.0 with: python-version: '3.11' - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - name: Check formatting with black run: | black improv_ble_client --check --diff - name: Lint with flake8 run: | flake8 improv_ble_client - name: Lint with isort run: | isort improv_ble_client - name: Lint with mypy run: | mypy improv_ble_client - name: Lint with pylint run: | pylint improv_ble_client home-assistant-libs-py-improv-ble-client-4b954d0/.gitignore000066400000000000000000000002151457127425600237140ustar00rootroot00000000000000.DS_Store .idea *.log tmp/ *.py[cod] *.egg htmlcov .projectile .venv/ venv/ .mypy_cache/ *.egg-info/ # Visual Studio Code .vscode/* dist home-assistant-libs-py-improv-ble-client-4b954d0/LICENSE000066400000000000000000000020641457127425600227350ustar00rootroot00000000000000MIT License Copyright (c) 2023 Home Assistant Team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. home-assistant-libs-py-improv-ble-client-4b954d0/README.md000066400000000000000000000001431457127425600232030ustar00rootroot00000000000000# Python Improv via BLE Client Python package to provision devices which implement Improv via BLE home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/000077500000000000000000000000001457127425600254225ustar00rootroot00000000000000home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/__init__.py000066400000000000000000000006121457127425600275320ustar00rootroot00000000000000"""Improv via BLE client.""" from __future__ import annotations from . import errors from .client import ImprovBLEClient, device_filter from .protocol import SERVICE_DATA_UUID, SERVICE_UUID, Error, ImprovServiceData, State __all__ = [ "SERVICE_DATA_UUID", "SERVICE_UUID", "Error", "State", "ImprovBLEClient", "ImprovServiceData", "device_filter", "errors", ] home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/client.py000066400000000000000000000456101457127425600272600ustar00rootroot00000000000000"""Improv via BLE client.""" from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress from enum import Enum, IntEnum, IntFlag import logging from typing import Any, TypeVar, cast from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.backends.service import BleakGATTServiceCollection from bleak_retry_connector import ( BleakClientWithServiceCache, establish_connection, retry_bluetooth_connection_error, ) from . import protocol as prot from .errors import ( CharacteristicMissingError, Disconnected, ImprovError, InvalidCommand, NotConnected, ProvisioningFailed, Timeout, UnexpectedDisconnect, ) from .models import DisconnectReason from .protocol import ( _CMD_T, CHARACTERISTIC_UUID_CAPABILITIES, CHARACTERISTIC_UUID_ERROR, CHARACTERISTIC_UUID_RPC_COMMAND, CHARACTERISTIC_UUID_RPC_RESULT, CHARACTERISTIC_UUID_STATE, IMPROV_CHARACTERISTICS, SERVICE_DATA_UUID, SERVICE_UUID, STATE_MAP, parse_result, ) from .util import try_parse_enum _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") _EnumT = TypeVar("_EnumT", bound=Enum) DISCONNECT_DELAY = 90 DEFAULT_ATTEMPTS = 3 def device_filter(advertisement_data: AdvertisementData) -> bool: """Return True if the device is supported and ready to be provisioned.""" uuids = advertisement_data.service_uuids service_data = advertisement_data.service_data if SERVICE_UUID not in uuids or SERVICE_DATA_UUID not in service_data: return False try: improv_service_data = prot.ImprovServiceData.from_bytes( service_data[SERVICE_DATA_UUID] ) except InvalidCommand: return False return improv_service_data.state not in ( prot.State.PROVISIONING, prot.State.PROVISIONED, ) class NotificationHandler: """Container for notification handlers.""" error_callbacks: list[Callable[[prot.Error], None]] state_callbacks: list[Callable[[prot.State], None]] def __init__(self) -> None: """Initialize.""" self.reset() def notify(self, state: prot.Error | prot.State) -> None: """Handle notification update.""" if isinstance(state, prot.Error): for error_callback in self.error_callbacks: error_callback(state) else: for state_callback in self.state_callbacks: state_callback(state) def reset(self) -> None: """Reset.""" self.error_callbacks = [] self.state_callbacks = [] def subscribe_error( self, callback: Callable[[prot.Error], None] ) -> Callable[[], None]: """Subscribe to error notifications.""" def remove() -> None: with suppress(ValueError): self.error_callbacks.remove(callback) self.error_callbacks.append(callback) return remove def subscribe_state( self, callback: Callable[[prot.State], None] ) -> Callable[[], None]: """Subscribe to state notifications.""" def remove() -> None: with suppress(ValueError): self.state_callbacks.remove(callback) self.state_callbacks.append(callback) return remove class ImprovBLEClient: """Provision a device with support for Improv over BLE.""" _key_holder_id: bytes | None = None _secret: bytes | None = None def __init__( self, ble_device: BLEDevice, advertisement_data: AdvertisementData | None = None ): """Initialize.""" self._advertisement_data = advertisement_data self._background_tasks: set[asyncio.Task] = set() self._ble_device = ble_device self._client: BleakClient | None = None self._notification_handlers = NotificationHandler() self._response_handlers: dict[int, asyncio.Future[prot.Command]] = {} self._connect_lock = asyncio.Lock() self._disconnect_reason: DisconnectReason | None = None self._disconnect_timer: asyncio.TimerHandle | None = None self._expected_disconnect = False self._procedure_lock = asyncio.Lock() self.loop = asyncio.get_running_loop() def set_ble_device_and_advertisement_data( self, ble_device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Set the ble device.""" self._ble_device = ble_device self._advertisement_data = advertisement_data @property def address(self) -> str: """Get the address of the device.""" return str(self._ble_device.address) @property def name(self) -> str: """Get the name of the device.""" return str(self._ble_device.name or self._ble_device.address) @property def rssi(self) -> int | None: """Get the rssi of the device.""" if self._advertisement_data: return self._advertisement_data.rssi return None async def can_identify(self) -> bool: """Return if the device supports identify.""" _LOGGER.debug("%s: can_identify", self.name) async def _can_identify() -> bool: capabilities = await self.read_characteristic( CHARACTERISTIC_UUID_CAPABILITIES ) return bool(capabilities & prot.Capabilities.IDENTIFY) return await self._execute(_can_identify) async def identify(self) -> None: """Identify the device.""" _LOGGER.debug("%s: identify", self.name) async def _identify() -> None: await self.send_cmd(prot.IdentifyCmd()) await self._execute(_identify) async def need_authorization(self) -> bool: """Return if the device needs authorization.""" _LOGGER.debug("%s: need_authorization", self.name) async def _need_authorization() -> bool: state = await self.read_characteristic(CHARACTERISTIC_UUID_STATE) return state == prot.State.AUTHORIZATION_REQUIRED return await self._execute(_need_authorization) async def provision( self, ssid: str, password: str, state_callback: Callable[[prot.State], None] | None, ) -> str | None: """Provision the device. Returns the redirect url or None. """ _LOGGER.debug("%s: provision ssid: %s, pw: %s", self.name, ssid, password) async def _provision() -> str | None: """Execute the procedure""" def handle_error(value: prot.Error) -> None: if value == prot.Error.NO_ERROR or error_fut.done(): return error_fut.set_result(value) def handle_state(state: prot.State) -> None: if state_callback: state_callback(state) subscriptions = [ self._notification_handlers.subscribe_error(handle_error), self._notification_handlers.subscribe_state(handle_state), ] error_fut: asyncio.Future[prot.Error] = self.loop.create_future() provisioned_fut = self.receive_response(prot.WiFiSettingsRes) try: await self.send_cmd( prot.WiFiSettingsCmd(bytes(ssid, "utf-8"), bytes(password, "utf-8")) ) done, pending = await asyncio.wait( (error_fut, provisioned_fut), return_when=asyncio.FIRST_COMPLETED, ) for future in pending: future.cancel() if done.pop() is error_fut: raise ProvisioningFailed(error_fut.result()) if (redirect_url := provisioned_fut.result().redirect_url) is None: return None return redirect_url.decode() finally: for unsub in subscriptions: unsub() return await self._execute(_provision) async def subscribe_state_updates( self, state_callback: Callable[[prot.State], None] ) -> Callable[[], None]: """Subscribe to state updates. When subscribing, state_callback is be called with the current state If the device disconnects, state_callback is called with State.DISCONNECTED """ _LOGGER.debug("%s: subscribe_state_updates", self.name) async def _subscribe_state_updates() -> Callable[[], None]: state = cast( prot.State, await self.read_characteristic(CHARACTERISTIC_UUID_STATE), ) state_callback(state) return self._notification_handlers.subscribe_state(state_callback) return await self._execute(_subscribe_state_updates) @retry_bluetooth_connection_error(DEFAULT_ATTEMPTS) # type: ignore[misc] async def _execute(self, procedure: Callable[[], Coroutine[Any, Any, _T]]) -> _T: """Execute a procedure.""" if self._procedure_lock.locked(): _LOGGER.debug( "%s: Procedure already in progress, waiting for it to complete; " "RSSI: %s", self.name, self.rssi, ) async with self._procedure_lock: try: await self._ensure_connected() return await procedure() except asyncio.CancelledError as err: if self._disconnect_reason is None: raise ImprovError from err if self._disconnect_reason == DisconnectReason.TIMEOUT: raise Timeout from err if self._disconnect_reason == DisconnectReason.UNEXPECTED: raise UnexpectedDisconnect from err raise Disconnected(self._disconnect_reason) from err except ImprovError: self._disconnect(DisconnectReason.ERROR) raise async def _ensure_connected(self) -> None: """Ensure connection to device is established.""" if self._connect_lock.locked(): _LOGGER.debug( "%s: Connection already in progress, waiting for it to complete; " "RSSI: %s", self.name, self.rssi, ) if self._client and self._client.is_connected: self._reset_disconnect_timer() return async with self._connect_lock: # Check again while holding the lock if self._client and self._client.is_connected: self._reset_disconnect_timer() return _LOGGER.debug("%s: Connecting; RSSI: %s", self.name, self.rssi) client = await establish_connection( BleakClientWithServiceCache, self._ble_device, self.name, self._disconnected, use_services_cache=False, # True ble_device_callback=lambda: self._ble_device, ) _LOGGER.debug("%s: Connected; RSSI: %s", self.name, self.rssi) self._client = client # Make sure the device has all improv characteristics try: self._resolve_characteristics(client.services) except CharacteristicMissingError as err: _LOGGER.debug( "%s: characteristic missing, clearing cache: %s; RSSI: %s", self.name, err, self.rssi, exc_info=True, ) await client.clear_cache() self._cancel_disconnect_timer() await self._execute_disconnect_with_lock(DisconnectReason.ERROR) raise self._disconnect_reason = None self._reset_disconnect_timer() _LOGGER.debug( "%s: Subscribe to notifications; RSSI: %s", self.name, self.rssi ) await client.start_notify( CHARACTERISTIC_UUID_ERROR, self._notification_handler ) await client.start_notify( CHARACTERISTIC_UUID_RPC_RESULT, self._rpc_result_handler ) await client.start_notify( CHARACTERISTIC_UUID_STATE, self._notification_handler ) def _resolve_characteristics(self, services: BleakGATTServiceCollection) -> None: """Resolve characteristics.""" for characteristic in IMPROV_CHARACTERISTICS: if not services.get_characteristic(characteristic): raise CharacteristicMissingError(characteristic) def _raise_if_not_connected(self) -> None: """Raise if the connection to device is lost.""" if self._client and self._client.is_connected: self._reset_disconnect_timer() return raise NotConnected def _cancel_disconnect_timer(self): """Cancel disconnect timer.""" if self._disconnect_timer: self._disconnect_timer.cancel() self._disconnect_timer = None def _reset_disconnect_timer(self) -> None: """Reset disconnect timer. If the disconnect timer expires, disconnect from the device. """ async def _disconnect() -> None: """Execute disconnect request.""" _LOGGER.debug( "%s: Disconnecting after timeout of %s", self.name, DISCONNECT_DELAY, ) await self._execute_disconnect(DisconnectReason.TIMEOUT) def _schedule_disconnect() -> None: self._cancel_disconnect_timer() self._async_create_background_task(_disconnect()) self._cancel_disconnect_timer() self._expected_disconnect = False self._disconnect_timer = self.loop.call_later( DISCONNECT_DELAY, _schedule_disconnect ) def _disconnected(self, client: BleakClient) -> None: """Disconnected callback from Bleak.""" if self._expected_disconnect: _LOGGER.debug( "%s: Disconnected from device; RSSI: %s", self.name, self.rssi ) return _LOGGER.warning( "%s: Device unexpectedly disconnected; RSSI: %s", self.name, self.rssi, ) self._client = None self._disconnect(DisconnectReason.UNEXPECTED) def _disconnect(self, reason: DisconnectReason) -> None: """Schedule disconnect from device.""" self._async_create_background_task(self._execute_disconnect(reason)) async def _execute_disconnect(self, reason: DisconnectReason) -> None: """Execute disconnection.""" _LOGGER.debug("%s: Execute disconnect", self.name) if self._connect_lock.locked(): _LOGGER.debug( "%s: Disconnect already in progress, waiting for it to complete; " "RSSI: %s", self.name, self.rssi, ) async with self._connect_lock: await self._execute_disconnect_with_lock(reason) _LOGGER.debug("%s: Execute disconnect done", self.name) async def _execute_disconnect_with_lock(self, reason: DisconnectReason) -> None: """Execute disconnection.""" assert self._connect_lock.locked(), "Lock not held" client = self._client self._client = None if client and client.is_connected: self._expected_disconnect = True await client.disconnect() self._reset(reason) def _reset(self, reason: DisconnectReason) -> None: """Reset.""" _LOGGER.debug("%s: reset", self.name) self._notification_handlers.notify(prot.State.DISCONNECTED) self._notification_handlers.reset() for fut in self._response_handlers.values(): fut.cancel() self._response_handlers = {} self._disconnect_reason = reason self._cancel_disconnect_timer() def _validate_state( self, characteristic_uuid: str, data: bytes ) -> IntEnum | IntFlag | None: if ( len(data) != 1 or (state := try_parse_enum(STATE_MAP[characteristic_uuid], data[0])) is None ): _LOGGER.warning( "Unexpected characteristic data %s:%s", characteristic_uuid, data.hex(), ) return None return cast(IntEnum | IntFlag | None, state) async def _notification_handler( self, characteristic: BleakGATTCharacteristic, data: bytes ) -> None: self._reset_disconnect_timer() if (state := self._validate_state(characteristic.uuid, data)) is None: self._disconnect(DisconnectReason.INVALID_COMMAND) return _LOGGER.debug("Notification: %s: %s", characteristic.uuid, state.name) self._notification_handlers.notify(cast(prot.Error | prot.State, state)) async def _rpc_result_handler( self, characteristic: BleakGATTCharacteristic, data: bytes ) -> None: """Notification handler.""" self._reset_disconnect_timer() try: command = parse_result(data) except InvalidCommand as err: _LOGGER.warning("Received invalid command %s (%s)", err, data.hex()) self._disconnect(DisconnectReason.INVALID_COMMAND) return _LOGGER.debug("RX: %s (%s)", command, data.hex()) if fut := self._response_handlers.pop(command.cmd_id, None): if fut and not fut.done(): fut.set_result(command) async def read_characteristic(self, characteristic_uuid: str) -> IntEnum | IntFlag: """Read characteristic.""" self._raise_if_not_connected() assert self._client data = await self._client.read_gatt_char(characteristic_uuid) if (state := self._validate_state(characteristic_uuid, data)) is None: self._disconnect(DisconnectReason.INVALID_COMMAND) raise InvalidCommand return state async def send_cmd(self, command: prot.Command) -> None: """Send a command.""" data = command.as_bytes() _LOGGER.debug("TX: %s (%s)", command, data.hex()) self._raise_if_not_connected() assert self._client await self._client.write_gatt_char(CHARACTERISTIC_UUID_RPC_COMMAND, data, True) def receive_response(self, cmd: type[_CMD_T]) -> asyncio.Future[_CMD_T]: """Receive a response.""" fut: asyncio.Future[_CMD_T] = self.loop.create_future() self._response_handlers[cmd.cmd_id] = cast(asyncio.Future[prot.Command], fut) return fut def _async_create_background_task( self, func: Coroutine[Any, Any, _T] ) -> asyncio.Task[_T]: """Create a background task and add it to the set of background tasks.""" task = asyncio.create_task(func) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) return task home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/errors.py000066400000000000000000000027421457127425600273150ustar00rootroot00000000000000"""Exceptions.""" from __future__ import annotations from typing import TYPE_CHECKING from bleak.exc import BleakError from bleak_retry_connector import BLEAK_RETRY_EXCEPTIONS as BLEAK_EXCEPTIONS from .models import DisconnectReason if TYPE_CHECKING: from .protocol import Error class ImprovError(Exception): """Base class for exceptions.""" class CharacteristicMissingError(Exception): """Raised when a characteristic is missing.""" class CommandFailed(ImprovError): """Raised when a command fails.""" class Disconnected(ImprovError): """Raised when the connection is lost.""" def __init__(self, reason: DisconnectReason): self.reason = reason super().__init__(reason.name) class InvalidCommand(ImprovError): """Raised when a received command can't be parsed.""" class NotConnected(ImprovError): """Raised when connection is lost while sending a command.""" class ProvisioningFailed(CommandFailed): """Raised when provisioning fails.""" def __init__(self, error: Error): self.error = error super().__init__(error.name) class Timeout(BleakError, ImprovError): """Raised when am operation times out.""" class UnexpectedDisconnect(Disconnected, BleakError): """Raised when the connection is unexpectedly lost.""" def __init__(self): super().__init__(DisconnectReason.UNEXPECTED) IMPROV_EXCEPTIONS = ( *BLEAK_EXCEPTIONS, Disconnected, InvalidCommand, ProvisioningFailed, ) home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/models.py000066400000000000000000000003521457127425600272570ustar00rootroot00000000000000"""Models.""" from __future__ import annotations from enum import Enum, auto class DisconnectReason(Enum): """Disconnect reason.""" ERROR = auto() INVALID_COMMAND = auto() TIMEOUT = auto() UNEXPECTED = auto() home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/protocol.py000066400000000000000000000212371457127425600276420ustar00rootroot00000000000000"""Models for the Improv via BLE protocol.""" from __future__ import annotations from enum import IntEnum, IntFlag import struct from typing import Final, TypeVar from .errors import InvalidCommand SERVICE_UUID: Final = "00467768-6228-2272-4663-277478268000" SERVICE_DATA_UUID: Final = "00004677-0000-1000-8000-00805f9b34fb" CHARACTERISTIC_UUID_CAPABILITIES: Final = "00467768-6228-2272-4663-277478268005" CHARACTERISTIC_UUID_STATE: Final = "00467768-6228-2272-4663-277478268001" CHARACTERISTIC_UUID_ERROR: Final = "00467768-6228-2272-4663-277478268002" CHARACTERISTIC_UUID_RPC_COMMAND: Final = "00467768-6228-2272-4663-277478268003" CHARACTERISTIC_UUID_RPC_RESULT: Final = "00467768-6228-2272-4663-277478268004" IMPROV_CHARACTERISTICS = ( CHARACTERISTIC_UUID_CAPABILITIES, CHARACTERISTIC_UUID_ERROR, CHARACTERISTIC_UUID_RPC_COMMAND, CHARACTERISTIC_UUID_RPC_RESULT, CHARACTERISTIC_UUID_STATE, ) class Capabilities(IntFlag): """Capabilities.""" IDENTIFY = 1 class State(IntEnum): """State.""" AUTHORIZATION_REQUIRED = 1 AUTHORIZED = 2 PROVISIONING = 3 PROVISIONED = 4 DISCONNECTED = 0xFF class Error(IntEnum): """Error.""" NO_ERROR = 0 INVALID_RPC_PACKET = 1 UNKNOWN_RPC_COMMAND = 2 UNABLE_TO_CONNECT = 3 NOT_AUTHORIZED = 4 UNKNOWN_ERROR = 0xFF STATE_MAP: dict[str, type[IntEnum | IntFlag]] = { CHARACTERISTIC_UUID_CAPABILITIES: Capabilities, CHARACTERISTIC_UUID_ERROR: Error, CHARACTERISTIC_UUID_STATE: State, } HEADER = struct.Struct("!BB") class Command: """Base class for commands.""" cmd_id: int _format: struct.Struct _len: int _strings: list[bytes] def __init__(self, strings: list[bytes]) -> None: """Initialize.""" self._format = self._calc_format(strings) self._len = self._calc_len(strings) self._strings = strings def as_bytes(self) -> bytes: """Return serialized representation of the command.""" return self._pack(self._strings) @classmethod def from_bytes(cls, data: bytes) -> Command: """Initialize from serialized representation of the command.""" cls._validate(data) return cls(cls._extract_strings(data)) @property def _header(self) -> bytes: """Return packed header.""" return HEADER.pack(self.cmd_id, self._len) @staticmethod def _calc_checksum(data: bytes) -> int: """Calculate as simple sum checksum.""" return sum(data) & 0xFF @classmethod def _calc_format(cls, strings: list[bytes]) -> struct.Struct: if not strings: return struct.Struct("") fmt = "!" for string in strings: fmt += f"b{len(string)}s" return struct.Struct(fmt) @classmethod def _calc_len(cls, strings: list[bytes]) -> int: return cls._calc_format(strings).size @classmethod def _extract_strings(cls, data: bytes) -> list[bytes]: pos = 2 end = len(data) - 1 strings = [] while pos < end: str_len = data[pos] if pos + str_len > end: raise InvalidCommand("Invalid strings", data.hex()) pos += 1 strings.append(data[pos : pos + str_len]) pos += str_len if pos != end: raise InvalidCommand("Invalid strings", data.hex()) return strings def _pack(self, strings: list[bytes]) -> bytes: """Pack the command to bytes.""" tmp: list[int | bytes] = [] for string in strings: tmp.append(len(string)) tmp.append(string) data = self._header + self._format.pack(*tmp) return data + bytes([self._calc_checksum(data)]) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" if len(data) < 3 or (len(data) - 3) != data[1]: raise InvalidCommand("Invalid length", data.hex()) if hasattr(cls, "cmd_id") and data[0] != cls.cmd_id: raise InvalidCommand("Invalid cmd_id", data.hex()) if data[-1] != cls._calc_checksum(data[:-1]): raise InvalidCommand("Invalid checksum", data.hex()) strings = cls._extract_strings(data) if len(data) != 3 + cls._calc_len(strings): raise InvalidCommand("Invalid length", data.hex()) _CMD_T = TypeVar("_CMD_T", bound=Command) class UnknownCommand(Command): """Unknown command.""" def __init__(self, cmd_id: int, strings: list[bytes]): """Initialize.""" super().__init__(strings) self.cmd_id = cmd_id def __str__(self) -> str: return f"{self.__class__.__name__} data: {self.as_bytes().hex()}" @classmethod def from_bytes(cls, data: bytes) -> UnknownCommand: """Initialize from serialized representation of the command.""" cls._validate(data) return cls(data[0], cls._extract_strings(data)) class WiFiSettingsCmd(Command): """WiFi Settings Command.""" cmd_id = 0x01 def __init__(self, ssid: bytes, password: bytes) -> None: """Initialize.""" super().__init__([ssid, password]) self.ssid = ssid self.password = password def __str__(self) -> str: return ( f"{self.__class__.__name__} ssid:{self.ssid.hex()}, password:" f"{self.password.hex()}" ) @classmethod def from_bytes(cls, data: bytes) -> WiFiSettingsCmd: """Initialize from serialized representation of the command.""" cls._validate(data) strings = cls._extract_strings(data) return cls(strings[0], strings[1]) @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" super()._validate(data) if len(cls._extract_strings(data)) != 2: raise InvalidCommand("Invalid strings", data.hex()) class WiFiSettingsRes(Command): """WiFi Settings Response.""" cmd_id = 0x01 redirect_url: bytes | None def __init__(self, redirect_url: bytes | None, extra_strings: list[bytes]) -> None: """Initialize.""" if redirect_url is not None: strings = [redirect_url] + extra_strings else: strings = extra_strings super().__init__(strings) self.redirect_url = redirect_url def __str__(self) -> str: url: str | None = None if self.redirect_url is not None: url = self.redirect_url.decode() return f"{self.__class__.__name__} url:'{url}'" @classmethod def from_bytes(cls, data: bytes) -> Command: """Initialize from serialized representation of the command.""" cls._validate(data) strings = cls._extract_strings(data) redirect_url: bytes | None = None extra_strings: list[bytes] = [] if strings: redirect_url = strings[0] extra_strings = strings[1:] return cls(redirect_url, extra_strings) class IdentifyCmd(Command): """Identify Command.""" cmd_id = 0x02 def __init__(self) -> None: """Initialize.""" super().__init__([]) def __str__(self) -> str: return f"{self.__class__.__name__}" @classmethod def from_bytes(cls, data: bytes) -> IdentifyCmd: """Initialize from serialized representation of the command.""" cls._validate(data) return cls() @classmethod def _validate(cls, data: bytes) -> None: """Raise if the data is not valid.""" super()._validate(data) if len(cls._extract_strings(data)) != 0: raise InvalidCommand("Invalid strings", data.hex()) class ImprovServiceData: """Service data.""" def __init__(self, state: State, capabilities: Capabilities) -> None: """Initialize.""" self.capabilities = capabilities self.state = state @classmethod def from_bytes(cls, data: bytes) -> ImprovServiceData: """Initialize from serialized representation of the command.""" if len(data) != 6: raise InvalidCommand("Invalid service data", data.hex()) try: state = State(data[0]) capabilities = Capabilities(data[1]) except ValueError as exc: raise InvalidCommand("Invalid service data", data.hex()) from exc return cls(state, capabilities) RESULT_TYPES: dict[int, type[Command]] = { 0x01: WiFiSettingsRes, } def parse_result(data: bytes) -> Command: """Parse data and return Command.""" if len(data) < 3 or (len(data) - 3) != data[1]: raise InvalidCommand("Invalid length", data.hex()) if command_type := RESULT_TYPES.get(data[0]): tmp = command_type.from_bytes(data) return tmp return UnknownCommand.from_bytes(data) home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/py.typed000066400000000000000000000000001457127425600271070ustar00rootroot00000000000000home-assistant-libs-py-improv-ble-client-4b954d0/improv_ble_client/util.py000066400000000000000000000006371457127425600267570ustar00rootroot00000000000000"""Utility functions.""" from __future__ import annotations import contextlib from enum import Enum from typing import Any, TypeVar _EnumT = TypeVar("_EnumT", bound=Enum) def try_parse_enum(cls: type[_EnumT], value: Any) -> _EnumT | None: """Try to parse the value into an Enum. Return None if parsing fails. """ with contextlib.suppress(ValueError): return cls(value) return None home-assistant-libs-py-improv-ble-client-4b954d0/pyproject.toml000066400000000000000000000055231457127425600246470ustar00rootroot00000000000000[build-system] requires = ["setuptools~=65.6", "wheel~=0.37.1"] build-backend = "setuptools.build_meta" [project] name = "py-improv-ble-client" version = "1.0.4" license = {text = "MIT"} description = "API to provision devices which implement Improv via BLE" readme = "README.md" requires-python = ">=3.11.0" [project.urls] "Homepage" = "https://github.com/home-assistant-libs/py-improv-ble-client" [tool.setuptools] platforms = ["any"] zip-safe = true include-package-data = true [tool.setuptools.packages.find] include = ["improv_ble_client*"] [tool.setuptools.package-data] "*" = ["py.typed"] [tool.black] target-version = ["py311"] extend-exclude = "/generated/" [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings profile = "black" # will group `import x` and `from x import` of the same module. force_sort_within_sections = true known_first_party = [ "improv_ble_client", "tests", ] forced_separate = [ "tests", ] combine_as_imports = true [tool.pylint.MAIN] py-version = "3.11" ignore = [ "tests", ] # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 init-hook = """\ from pathlib import Path; \ import sys; \ from pylint.config import find_default_config_files; \ sys.path.append( \ str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) ) \ """ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", ] [tool.pylint.BASIC] class-const-naming-style = "any" good-names = [ "_CMD_T", ] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # format - handled by black # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable disable = [ "format", "cyclic-import", "duplicate-code", "locally-disabled", "too-few-public-methods", "too-many-ancestors", "too-many-arguments", "too-many-branches", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-return-statements", "too-many-statements", "too-many-boolean-expressions", "unused-argument", "wrong-import-order", ] enable = [ "useless-suppression", "use-symbolic-message-instead", ] home-assistant-libs-py-improv-ble-client-4b954d0/requirements-test.txt000066400000000000000000000001041457127425600261620ustar00rootroot00000000000000black==24.2.0 flake8==7.0.0 isort==5.13.2 mypy==1.8.0 pylint==3.1.0 home-assistant-libs-py-improv-ble-client-4b954d0/requirements.txt000066400000000000000000000000341457127425600252070ustar00rootroot00000000000000bleak bleak-retry-connector home-assistant-libs-py-improv-ble-client-4b954d0/setup.cfg000066400000000000000000000001461457127425600235500ustar00rootroot00000000000000[flake8] # To work with Black max-line-length = 88 # E203: Whitespace before ':' extend-ignore = E203