Convert fjäråskupan to built in bluetooth ()

* Add bluetooth discovery

* Use home assistant standard api

* Fixup manufacture data

* Adjust config flow to use standard features

* Fixup tests

* Mock bluetooth

* Simplify device check

* Fix missing typing

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/76047/head
Joakim Plate 2022-08-01 16:56:08 +02:00 committed by GitHub
parent f2da46d99b
commit 2dd62b14b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 109 additions and 132 deletions
homeassistant
tests/components/fjaraskupan

View File

@ -5,14 +5,20 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from bleak import BleakScanner
from fjaraskupan import Device, State, device_filter
from fjaraskupan import Device, State
from homeassistant.components.bluetooth import (
BluetoothCallbackMatcher,
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
async_address_present,
async_register_callback,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@ -23,11 +29,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DISPATCH_DETECTION, DOMAIN
if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.FAN,
@ -70,16 +71,18 @@ class Coordinator(DataUpdateCoordinator[State]):
async def _async_update_data(self) -> State:
"""Handle an explicit update request."""
if self._refresh_was_scheduled:
raise UpdateFailed("No data received within schedule.")
if async_address_present(self.hass, self.device.address):
return self.device.state
raise UpdateFailed(
"No data received within schedule, and device is no longer present"
)
await self.device.update()
return self.device.state
def detection_callback(
self, ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
def detection_callback(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Handle a new announcement of data."""
self.device.detection_callback(ble_device, advertisement_data)
self.device.detection_callback(service_info.device, service_info.advertisement)
self.async_set_updated_data(self.device.state)
@ -87,59 +90,52 @@ class Coordinator(DataUpdateCoordinator[State]):
class EntryState:
"""Store state of config entry."""
scanner: BleakScanner
coordinators: dict[str, Coordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Fjäråskupan from a config entry."""
scanner = BleakScanner(filters={"DuplicateData": True})
state = EntryState(scanner, {})
state = EntryState({})
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = state
async def detection_callback(
ble_device: BLEDevice, advertisement_data: AdvertisementData
def detection_callback(
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
) -> None:
if data := state.coordinators.get(ble_device.address):
_LOGGER.debug(
"Update: %s %s - %s", ble_device.name, ble_device, advertisement_data
)
data.detection_callback(ble_device, advertisement_data)
if change != BluetoothChange.ADVERTISEMENT:
return
if data := state.coordinators.get(service_info.address):
_LOGGER.debug("Update: %s", service_info)
data.detection_callback(service_info)
else:
if not device_filter(ble_device, advertisement_data):
return
_LOGGER.debug("Detected: %s", service_info)
_LOGGER.debug(
"Detected: %s %s - %s", ble_device.name, ble_device, advertisement_data
)
device = Device(ble_device)
device = Device(service_info.device)
device_info = DeviceInfo(
identifiers={(DOMAIN, ble_device.address)},
identifiers={(DOMAIN, service_info.address)},
manufacturer="Fjäråskupan",
name="Fjäråskupan",
)
coordinator: Coordinator = Coordinator(hass, device, device_info)
coordinator.detection_callback(ble_device, advertisement_data)
coordinator.detection_callback(service_info)
state.coordinators[ble_device.address] = coordinator
state.coordinators[service_info.address] = coordinator
async_dispatcher_send(
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
)
scanner.register_detection_callback(detection_callback)
await scanner.start()
async def on_hass_stop(event: Event) -> None:
await scanner.stop()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
async_register_callback(
hass,
detection_callback,
BluetoothCallbackMatcher(
manufacturer_id=20296,
manufacturer_data_start=[79, 68, 70, 74, 65, 82],
),
BluetoothScanningMode.ACTIVE,
)
)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
@ -177,7 +173,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id)
await entry_state.scanner.stop()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -1,42 +1,25 @@
"""Config flow for Fjäråskupan integration."""
from __future__ import annotations
import asyncio
import async_timeout
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from fjaraskupan import device_filter
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_flow import register_discovery_flow
from .const import DOMAIN
CONST_WAIT_TIME = 5.0
async def _async_has_devices(hass: HomeAssistant) -> bool:
"""Return if there are devices that can be discovered."""
event = asyncio.Event()
service_infos = async_discovered_service_info(hass)
def detection(device: BLEDevice, advertisement_data: AdvertisementData):
if device_filter(device, advertisement_data):
event.set()
for service_info in service_infos:
if device_filter(service_info.device, service_info.advertisement):
return True
async with BleakScanner(
detection_callback=detection,
filters={"DuplicateData": True},
):
try:
async with async_timeout.timeout(CONST_WAIT_TIME):
await event.wait()
except asyncio.TimeoutError:
return False
return True
return False
register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices)

View File

@ -6,5 +6,12 @@
"requirements": ["fjaraskupan==1.0.2"],
"codeowners": ["@elupus"],
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"]
"loggers": ["bleak", "fjaraskupan"],
"dependencies": ["bluetooth"],
"bluetooth": [
{
"manufacturer_id": 20296,
"manufacturer_data_start": [79, 68, 70, 74, 65, 82]
}
]
}

View File

@ -7,6 +7,18 @@ from __future__ import annotations
# fmt: off
BLUETOOTH: list[dict[str, str | int | list[int]]] = [
{
"domain": "fjaraskupan",
"manufacturer_id": 20296,
"manufacturer_data_start": [
79,
68,
70,
74,
65,
82
]
},
{
"domain": "govee_ble",
"local_name": "Govee*"

View File

@ -1 +1,11 @@
"""Tests for the Fjäråskupan integration."""
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import SOURCE_LOCAL, BluetoothServiceInfoBleak
COOKER_SERVICE_INFO = BluetoothServiceInfoBleak.from_advertisement(
BLEDevice("1.1.1.1", "COOKERHOOD_FJAR"), AdvertisementData(), source=SOURCE_LOCAL
)

View File

@ -1,47 +1,9 @@
"""Standard fixtures for the Fjäråskupan integration."""
from __future__ import annotations
from unittest.mock import patch
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, BaseBleakScanner
from pytest import fixture
import pytest
@fixture(name="scanner", autouse=True)
def fixture_scanner(hass):
"""Fixture for scanner."""
devices = [BLEDevice("1.1.1.1", "COOKERHOOD_FJAR")]
class MockScanner(BaseBleakScanner):
"""Mock Scanner."""
def __init__(self, *args, **kwargs) -> None:
"""Initialize the scanner."""
super().__init__(
detection_callback=kwargs.pop("detection_callback"), service_uuids=[]
)
async def start(self):
"""Start scanning for devices."""
for device in devices:
self._callback(device, AdvertisementData())
async def stop(self):
"""Stop scanning for devices."""
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return discovered devices."""
return devices
def set_scanning_filter(self, **kwargs):
"""Set the scanning filter."""
with patch(
"homeassistant.components.fjaraskupan.config_flow.BleakScanner", new=MockScanner
), patch(
"homeassistant.components.fjaraskupan.config_flow.CONST_WAIT_TIME", new=0.01
):
yield devices
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View File

@ -3,7 +3,6 @@ from __future__ import annotations
from unittest.mock import patch
from bleak.backends.device import BLEDevice
from pytest import fixture
from homeassistant import config_entries
@ -11,6 +10,8 @@ from homeassistant.components.fjaraskupan.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import COOKER_SERVICE_INFO
@fixture(name="mock_setup_entry", autouse=True)
async def fixture_mock_setup_entry(hass):
@ -24,31 +25,38 @@ async def fixture_mock_setup_entry(hass):
async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.fjaraskupan.config_flow.async_discovered_service_info",
return_value=[COOKER_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Fjäråskupan"
assert result["data"] == {}
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Fjäråskupan"
assert result["data"] == {}
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_scan_no_devices(hass: HomeAssistant, scanner: list[BLEDevice]) -> None:
async def test_scan_no_devices(hass: HomeAssistant) -> None:
"""Test we get the form."""
scanner.clear()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.fjaraskupan.config_flow.async_discovered_service_info",
return_value=[],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"